import { describe, expect, mock, test } from 'bun:test'
import {
	batch,
	createEffect,
	createMemo,
	createScope,
	createState,
	createTask,
	match,
	RequiredOwnerError,
} from '../index.ts'

/* === Utility Functions === */

const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))

/* === Tests === */

describe('createEffect', () => {
	test('should run immediately on creation', () => {
		let ran = false
		createEffect(() => {
			ran = true
		})
		expect(ran).toBe(true)
	})

	test('should re-run when a tracked dependency changes', () => {
		const source = createState('foo')
		let count = 0
		createEffect(() => {
			source.get()
			count++
		})
		expect(count).toBe(1)
		source.set('bar')
		expect(count).toBe(2)
	})

	test('should re-run on each state change', () => {
		const source = createState(0)
		let result = 0
		createEffect(() => {
			result = source.get()
		})
		for (let i = 1; i <= 5; i++) {
			source.set(i)
			expect(result).toBe(i)
		}
	})

	test('should handle state updates inside effects', () => {
		const count = createState(0)
		let effectCount = 0
		createEffect(() => {
			effectCount++
			if (count.get() === 0) count.set(1)
		})
		expect(count.get()).toBe(1)
		expect(effectCount).toBe(2)
	})

	describe('Cleanup', () => {
		test('should call cleanup before next run', () => {
			const source = createState(0)
			let cleanupCount = 0
			let effectCount = 0

			createEffect(() => {
				source.get()
				effectCount++
				return () => {
					cleanupCount++
				}
			})

			expect(effectCount).toBe(1)
			expect(cleanupCount).toBe(0)

			source.set(1)
			expect(effectCount).toBe(2)
			expect(cleanupCount).toBe(1)

			source.set(2)
			expect(effectCount).toBe(3)
			expect(cleanupCount).toBe(2)
		})

		test('should call cleanup on disposal', () => {
			const source = createState(0)
			let cleanupCalled = false

			const dispose = createEffect(() => {
				source.get()
				return () => {
					cleanupCalled = true
				}
			})

			expect(cleanupCalled).toBe(false)
			dispose()
			expect(cleanupCalled).toBe(true)
		})

		test('should stop reacting after disposal', () => {
			const source = createState(42)
			let received = 0

			const dispose = createEffect(() => {
				received = source.get()
			})

			source.set(43)
			expect(received).toBe(43)

			dispose()
			source.set(44)
			expect(received).toBe(43)
		})
	})

	describe('Owner Registration', () => {
		test('should dispose nested effects when parent scope is disposed', () => {
			const source = createState(0)
			let innerRuns = 0

			const dispose = createScope(() => {
				createEffect(() => {
					source.get()
					innerRuns++
				})
			})

			expect(innerRuns).toBe(1)
			source.set(1)
			expect(innerRuns).toBe(2)

			dispose()
			source.set(2)
			expect(innerRuns).toBe(2) // no longer reacting
		})
	})

	describe('Watched memo equality', () => {
		test('should skip effect re-run when watched memo recomputes to same value', () => {
			let invalidate!: () => void
			let effectCount = 0

			// Memo whose computed value does not change on invalidation
			const memo = createMemo(() => 42, {
				value: 42,
				watched: inv => {
					invalidate = inv
					return () => {}
				},
			})

			const dispose = createScope(() => {
				createEffect(() => {
					void memo.get()
					effectCount++
				})
			})

			expect(effectCount).toBe(1)

			// Invalidate — memo recomputes but returns same value (42)
			invalidate()

			// Because equals(42, 42) is true, the effect should NOT re-run
			expect(effectCount).toBe(1)

			dispose()
		})

		test('should re-run effect when watched memo recomputes to different value', () => {
			let invalidate!: () => void
			let effectCount = 0
			let externalValue = 1

			const memo = createMemo(() => externalValue, {
				value: 0,
				watched: inv => {
					invalidate = inv
					return () => {}
				},
			})

			let observed = 0
			const dispose = createScope(() => {
				createEffect(() => {
					observed = memo.get()
					effectCount++
				})
			})

			expect(effectCount).toBe(1)
			expect(observed).toBe(1)

			// Change external value and invalidate — memo returns a new value
			externalValue = 99
			invalidate()

			expect(effectCount).toBe(2)
			expect(observed).toBe(99)

			dispose()
		})

		test('should respect custom equals to skip effect re-run', () => {
			let invalidate!: () => void
			let effectCount = 0
			let externalValue = 3

			// Custom equals: treat values as equal when they round to the same integer
			const memo = createMemo(() => externalValue, {
				value: 0,
				equals: (a, b) => Math.floor(a) === Math.floor(b),
				watched: inv => {
					invalidate = inv
					return () => {}
				},
			})

			const dispose = createScope(() => {
				createEffect(() => {
					void memo.get()
					effectCount++
				})
			})

			expect(effectCount).toBe(1)

			// External value changes slightly but rounds to same integer
			externalValue = 3.7
			invalidate()
			expect(effectCount).toBe(1) // equals says same → effect skipped

			// External value changes to a different integer
			externalValue = 4.1
			invalidate()
			expect(effectCount).toBe(2) // equals says different → effect runs

			dispose()
		})

		test('should skip effect re-run through memo chain when watched memo value unchanged', () => {
			let invalidate!: () => void
			let effectCount = 0

			const watchedMemo = createMemo(() => 42, {
				value: 42,
				watched: inv => {
					invalidate = inv
					return () => {}
				},
			})

			// Downstream memo that doubles the watched memo value
			const doubled = createMemo(() => watchedMemo.get() * 2)

			const dispose = createScope(() => {
				createEffect(() => {
					void doubled.get()
					effectCount++
				})
			})

			expect(effectCount).toBe(1)

			// Invalidate — watchedMemo recomputes to same value, so doubled
			// should also remain unchanged, and the effect should not re-run
			invalidate()
			expect(effectCount).toBe(1)

			dispose()
		})

		test('should skip effect when invalidate is called inside batch and value unchanged', () => {
			let invalidate!: () => void
			let effectCount = 0

			const memo = createMemo(() => 42, {
				value: 42,
				watched: inv => {
					invalidate = inv
					return () => {}
				},
			})

			const dispose = createScope(() => {
				createEffect(() => {
					void memo.get()
					effectCount++
				})
			})

			expect(effectCount).toBe(1)

			batch(() => {
				invalidate()
			})

			// Value didn't change so effect should still be at 1
			expect(effectCount).toBe(1)

			dispose()
		})

		test('should still run effect for dirty state even when watched memo unchanged', () => {
			let invalidate!: () => void
			let effectCount = 0
			const state = createState(1)

			const memo = createMemo(() => 42, {
				value: 42,
				watched: inv => {
					invalidate = inv
					return () => {}
				},
			})

			let observedState = 0
			const dispose = createScope(() => {
				createEffect(() => {
					observedState = state.get()
					void memo.get()
					effectCount++
				})
			})

			expect(effectCount).toBe(1)

			// Change the state AND invalidate — effect must run because state changed
			batch(() => {
				state.set(2)
				invalidate()
			})

			expect(effectCount).toBe(2)
			expect(observedState).toBe(2)

			dispose()
		})
	})

	describe('Input Validation', () => {
		test('should throw InvalidCallbackError for non-function', () => {
			// @ts-expect-error - Testing invalid input
			expect(() => createEffect(null)).toThrow(
				'[Effect] Callback null is invalid',
			)
			// @ts-expect-error - Testing invalid input
			expect(() => createEffect(42)).toThrow(
				'[Effect] Callback 42 is invalid',
			)
		})
	})
})

describe('match', () => {
	test('should call ok handler when all signals have values', () => {
		const a = createState(1)
		const b = createState(2)
		let result = 0
		createEffect(() =>
			match([a, b], {
				ok: ([aVal, bVal]) => {
					result = aVal + bVal
				},
			}),
		)
		expect(result).toBe(3)
	})

	test('should call nil handler when signals are unset', async () => {
		const task = createTask(async () => {
			await wait(50)
			return 42
		})
		let okCount = 0
		let nilCount = 0
		createEffect(() =>
			match([task], {
				ok: ([value]) => {
					okCount++
					expect(value).toBe(42)
				},
				nil: () => {
					nilCount++
				},
			}),
		)

		expect(okCount).toBe(0)
		expect(nilCount).toBe(1)
		await wait(60)
		expect(okCount).toBeGreaterThan(0)
		expect(nilCount).toBe(1)
	})

	test('should call err handler when signals throw', () => {
		const a = createState(1)
		const b = createMemo(() => {
			if (a.get() > 5) throw new Error('Too high')
			return a.get() * 2
		})
		let okCount = 0
		let errCount = 0
		createEffect(() =>
			match([b], {
				ok: () => {
					okCount++
				},
				err: errors => {
					errCount++
					// biome-ignore lint/style/noNonNullAssertion: test
					expect(errors[0]!.message).toBe('Too high')
				},
			}),
		)

		expect(okCount).toBe(1)
		a.set(6)
		expect(errCount).toBe(1)

		a.set(3)
		expect(okCount).toBe(2)
		expect(errCount).toBe(1)
	})

	test('should fall back to console.error when err handler is not provided', () => {
		const originalConsoleError = console.error
		const mockConsoleError = mock(() => {})
		console.error = mockConsoleError

		try {
			const a = createState(1)
			const b = createMemo(() => {
				if (a.get() > 5) throw new Error('Too high')
				return a.get() * 2
			})

			createEffect(() =>
				match([b], {
					ok: () => {},
				}),
			)

			a.set(6)
			expect(mockConsoleError).toHaveBeenCalled()
		} finally {
			console.error = originalConsoleError
		}
	})

	test('should preserve tuple types in ok handler', () => {
		const a = createState(1)
		const b = createState('hello')
		createEffect(() =>
			match([a, b], {
				ok: ([aVal, bVal]) => {
					// If tuple types are preserved, aVal is number and bVal is string
					// If widened, both would be string | number
					const num: number = aVal
					const str: string = bVal
					expect(num).toBe(1)
					expect(str).toBe('hello')
				},
			}),
		)
	})

	test('should throw RequiredOwnerError when called outside an owner', () => {
		expect(() => match([], { ok: () => {} })).toThrow(RequiredOwnerError)
	})

	describe('Single-signal overload', () => {
		test('should call ok with unwrapped value', () => {
			const s = createState(42)
			let result = 0
			createEffect(() =>
				match(s, {
					ok: value => {
						result = value
					},
				}),
			)
			expect(result).toBe(42)
			s.set(99)
			expect(result).toBe(99)
		})

		test('should call nil handler when signal is unset', async () => {
			const task = createTask(async () => {
				await wait(50)
				return 42
			})
			let okCount = 0
			let nilCount = 0
			createEffect(() =>
				match(task, {
					ok: value => {
						okCount++
						expect(value).toBe(42)
					},
					nil: () => {
						nilCount++
					},
				}),
			)
			expect(okCount).toBe(0)
			expect(nilCount).toBe(1)
			await wait(60)
			expect(okCount).toBeGreaterThan(0)
			expect(nilCount).toBe(1)
		})

		test('should call err with unwrapped Error', () => {
			const a = createState(1)
			const b = createMemo(() => {
				if (a.get() > 5) throw new Error('Too high')
				return a.get() * 2
			})
			let okCount = 0
			let errCount = 0
			createEffect(() =>
				match(b, {
					ok: () => {
						okCount++
					},
					err: error => {
						errCount++
						expect(error.message).toBe('Too high')
					},
				}),
			)
			expect(okCount).toBe(1)
			a.set(6)
			expect(errCount).toBe(1)
			a.set(3)
			expect(okCount).toBe(2)
			expect(errCount).toBe(1)
		})

		test('should fall back to console.error for single signal without err handler', () => {
			const originalConsoleError = console.error
			const mockConsoleError = mock(() => {})
			console.error = mockConsoleError

			try {
				const a = createState(1)
				const b = createMemo(() => {
					if (a.get() > 5) throw new Error('Too high')
					return a.get() * 2
				})
				createEffect(() => match(b, { ok: () => {} }))
				a.set(6)
				expect(mockConsoleError).toHaveBeenCalled()
			} finally {
				console.error = originalConsoleError
			}
		})
	})

	test('should resolve multiple async tasks without waterfalls', async () => {
		const a = createTask(async () => {
			await wait(20)
			return 10
		})
		const b = createTask(async () => {
			await wait(20)
			return 20
		})
		let result = 0
		let nilCount = 0
		createEffect(() =>
			match([a, b], {
				ok: ([aVal, bVal]) => {
					result = aVal + bVal
				},
				nil: () => {
					nilCount++
				},
			}),
		)
		expect(result).toBe(0)
		expect(nilCount).toBe(1)
		await wait(30)
		expect(result).toBe(30)
	})

	describe('stale handler', () => {
		// stale fires when: task.get() succeeds (retained value) AND task.isPending() is true.
		// recomputeTask() sets node.controller synchronously, so isPending() = true immediately
		// after the first get() call that triggers recomputation.
		test('should call stale on initial run when task has a seeded value and is computing', async () => {
			const task = createTask(async () => {
				await wait(50)
				return 99
			}, { value: 42 })
			let okCount = 0
			let staleCount = 0

			createEffect(() =>
				match(task, {
					ok: () => { okCount++ },
					stale: () => { staleCount++ },
				}),
			)

			// First run: task has 42 (seeded) but is computing → stale
			expect(staleCount).toBe(1)
			expect(okCount).toBe(0)

			await wait(60)
			// Resolved to 99 (changed): ok
			expect(okCount).toBe(1)
			expect(staleCount).toBe(1)
		})

		test('should call stale when another dependency changes while task is still pending', async () => {
			const other = createState(0)
			const task = createTask(async () => {
				await wait(100)
				return 42
			}, { value: 0 })
			const log: string[] = []

			createEffect(() => {
				const o = other.get()
				match(task, {
					ok: v => { log.push(`ok:${v}:${o}`) },
					stale: () => { log.push(`stale:${o}`) },
				})
			})

			// Initial run: task has seeded value 0, computing → stale
			expect(log).toEqual(['stale:0'])

			// While task is still in flight, another dependency changes → effect re-runs FLAG_DIRTY
			other.set(1)
			expect(log).toEqual(['stale:0', 'stale:1'])

			await wait(110)
			// Task resolved: effect re-runs, isPending() = false → ok
			expect(log[log.length - 1]).toMatch(/^ok:42:/)
		})

		test('should fall back to ok when stale handler is absent', async () => {
			const task = createTask(async () => {
				await wait(50)
				return 99
			}, { value: 42 })
			let okCount = 0

			createEffect(() =>
				match(task, {
					ok: () => { okCount++ },
				}),
			)

			// No stale handler: falls back to ok even while pending
			expect(okCount).toBe(1)
			await wait(60)
			// Resolved to 99 (different value): ok again
			expect(okCount).toBe(2)
		})

		test('should call stale for tuple overload when any task is re-computing', async () => {
			const a = createState(10)
			const task = createTask(async () => {
				await wait(50)
				return 99
			}, { value: 0 })
			let okCount = 0
			let staleCount = 0

			createEffect(() =>
				match([a, task], {
					ok: () => { okCount++ },
					stale: () => { staleCount++ },
				}),
			)

			// First run: task has seeded value 0, computing → stale
			expect(staleCount).toBe(1)
			expect(okCount).toBe(0)

			await wait(60)
			// Task resolved to 99: ok (with a=10)
			expect(okCount).toBe(1)
			expect(staleCount).toBe(1)
		})

		test('nil takes precedence over stale', async () => {
			// One task unresolved (no initial value → nil), one task with seeded value (stale)
			const staleTask = createTask(async () => {
				await wait(200)
				return 42
			}, { value: 0 })
			const nilTask = createTask(async () => {
				await wait(200)
				return 99
			})
			let nilCount = 0
			let staleCount = 0
			let okCount = 0

			createEffect(() =>
				match([staleTask, nilTask], {
					ok: () => { okCount++ },
					nil: () => { nilCount++ },
					stale: () => { staleCount++ },
				}),
			)

			// nilTask throws UnsetSignalValueError → pending = true → nil wins over stale
			expect(nilCount).toBe(1)
			expect(staleCount).toBe(0)
			expect(okCount).toBe(0)
		})

		test('should call stale on re-fetch after task has previously resolved', async () => {
			const source = createState(1)
			const task = createTask(
				async () => {
					const val = source.get()
					await wait(50)
					return val * 10
				},
				{ value: 0 },
			)
			const log: string[] = []

			createEffect(() =>
				match(task, {
					ok: v => {
						log.push(`ok:${v}`)
					},
					stale: () => {
						log.push('stale')
					},
				}),
			)

			expect(log).toEqual(['stale'])

			await wait(60)
			expect(log).toEqual(['stale', 'ok:10'])

			// Core bug: source changes → task re-fetches → stale must fire
			source.set(2)
			expect(log).toEqual(['stale', 'ok:10', 'stale'])

			await wait(60)
			expect(log).toEqual(['stale', 'ok:10', 'stale', 'ok:20'])
		})

		test('should transition stale → ok when re-fetch resolves to same value', async () => {
			const source = createState(1)
			const task = createTask(
				async () => {
					source.get()
					await wait(50)
					return 42
				},
				{ value: 42 },
			)
			const log: string[] = []

			createEffect(() =>
				match(task, {
					ok: () => {
						log.push('ok')
					},
					stale: () => {
						log.push('stale')
					},
				}),
			)

			expect(log).toEqual(['stale'])

			await wait(60)
			// Resolved to 42 (same as seed) — stale → ok transition must fire
			expect(log).toEqual(['stale', 'ok'])

			source.set(2)
			expect(log).toEqual(['stale', 'ok', 'stale'])

			await wait(60)
			// Re-resolves to 42 again (value unchanged) — must still transition to ok
			expect(log).toEqual(['stale', 'ok', 'stale', 'ok'])
		})

		test('stale cleanup runs before next dispatch', async () => {
			const task = createTask(async () => {
				await wait(50)
				return 99
			}, { value: 42 })
			let cleanupCount = 0

			createEffect(() =>
				match(task, {
					ok: () => {},
					stale: () => () => { cleanupCount++ },
				}),
			)

			// First run: stale → cleanup function registered
			expect(cleanupCount).toBe(0)
			await wait(60)
			// Task resolved (42 → 99): effect re-runs, cleanup runs first, then ok
			expect(cleanupCount).toBe(1)
		})
	})

	describe('Async Handlers', () => {
		test('should not register cleanup from stale async handler after disposal', async () => {
			let cleanupRegistered = false

			const dispose = createEffect(() =>
				match([], {
					ok: async () => {
						await wait(50)
						return () => {
							cleanupRegistered = true
						}
					},
				}),
			)

			await wait(10)
			dispose()
			await wait(60)

			expect(cleanupRegistered).toBe(false)
		})

		test('should register and run cleanup from completed async handler', async () => {
			let cleanupCalled = false

			const dispose = createEffect(() =>
				match([], {
					ok: async () => {
						await wait(10)
						return () => {
							cleanupCalled = true
						}
					},
				}),
			)

			await wait(20)
			dispose()
			expect(cleanupCalled).toBe(true)
		})

		test('should route async errors to err handler', async () => {
			const originalConsoleError = console.error
			const mockConsoleError = mock(() => {})
			console.error = mockConsoleError

			try {
				const source = createState(1)

				createEffect(() =>
					match([source], {
						ok: async ([value]) => {
							await wait(10)
							if (value > 3) throw new Error('Async error')
						},
					}),
				)

				source.set(4)
				await wait(20)

				expect(mockConsoleError).toHaveBeenCalled()
			} finally {
				console.error = originalConsoleError
			}
		})

		test('should discard stale async cleanup when effect re-runs', async () => {
			const source = createState(1)
			let staleCleanupCalled = false
			let freshCleanupCalled = false

			const dispose = createEffect(() =>
				match([source], {
					ok: async ([value]) => {
						if (value === 1) {
							await wait(80)
							return () => {
								staleCleanupCalled = true
							}
						}
						await wait(10)
						return () => {
							freshCleanupCalled = true
						}
					},
				}),
			)

			await wait(20)
			source.set(2)
			await wait(100)

			expect(staleCleanupCalled).toBe(false)

			dispose()
			expect(freshCleanupCalled).toBe(true)
		})

		test('should call async cleanup before re-running', async () => {
			const source = createState(0)
			let cleanupCount = 0
			let okCount = 0

			createEffect(() =>
				match([source], {
					ok: async () => {
						okCount++
						await wait(10)
						return () => {
							cleanupCount++
						}
					},
				}),
			)

			await wait(20)
			expect(okCount).toBe(1)
			expect(cleanupCount).toBe(0)

			source.set(1)
			expect(cleanupCount).toBe(1)
			await wait(20)
			expect(okCount).toBe(2)
		})
	})

	describe('err handler cleanup', () => {
		test('cleanup returned by err is called when ok handler throws', () => {
			const source = createState(1)
			let cleanupCount = 0
			createEffect(() =>
				match(source, {
					ok: () => {
						throw new Error('ok failed')
					},
					err: () => () => {
						cleanupCount++
					},
				}),
			)
			expect(cleanupCount).toBe(0) // no cleanup on first run
			source.set(2) // re-run should call previous cleanup
			expect(cleanupCount).toBe(1)
		})
	})
})
