import { describe, expect, test } from 'bun:test'
import {
	batch,
	createEffect,
	createMemo,
	createScope,
	createState,
	isMemo,
	isState,
	UnsetSignalValueError,
} from '../index.ts'

/* === Tests === */

describe('Memo', () => {
	describe('createMemo', () => {
		test('should compute a derived value', () => {
			const derived = createMemo(() => 1 + 2)
			expect(derived.get()).toBe(3)
		})

		test('should have Symbol.toStringTag of "Memo"', () => {
			const memo = createMemo(() => 0)
			expect(memo[Symbol.toStringTag]).toBe('Memo')
		})

		test('should evaluate lazily on first get()', () => {
			let computed = false
			const memo = createMemo(() => {
				computed = true
				return 42
			})
			expect(computed).toBe(false)
			memo.get()
			expect(computed).toBe(true)
		})

		test('should throw UnsetSignalValueError if callback returns undefined', () => {
			const memo = createMemo(() => undefined as unknown as number)
			expect(() => memo.get()).toThrow(UnsetSignalValueError)
		})
	})

	describe('isMemo', () => {
		test('should identify memo signals', () => {
			expect(isMemo(createMemo(() => 0))).toBe(true)
		})

		test('should return false for non-memo values', () => {
			expect(isMemo(42)).toBe(false)
			expect(isMemo(null)).toBe(false)
			expect(isMemo({})).toBe(false)
			expect(isState(createMemo(() => 0))).toBe(false)
		})
	})

	describe('Dependency Tracking', () => {
		test('should recompute when a dependency changes', () => {
			const source = createState(42)
			const derived = createMemo(() => source.get() + 1)
			expect(derived.get()).toBe(43)
			source.set(24)
			expect(derived.get()).toBe(25)
		})

		test('should track through a chain of memos', () => {
			const x = createState(42)
			const a = createMemo(() => x.get() + 1)
			const b = createMemo(() => a.get() * 2)
			const c = createMemo(() => b.get() + 1)
			expect(c.get()).toBe(87)
			x.set(24)
			expect(c.get()).toBe(51)
		})

		test('should recompute after multiple state changes', () => {
			const a = createState(3)
			const b = createState(4)
			let count = 0
			const sum = createMemo(() => {
				count++
				return a.get() + b.get()
			})
			expect(sum.get()).toBe(7)
			a.set(6)
			expect(sum.get()).toBe(10)
			b.set(8)
			expect(sum.get()).toBe(14)
			expect(count).toBe(3)
		})
	})

	describe('Memoization', () => {
		test('should skip downstream recomputation when result is unchanged', () => {
			let count = 0
			const x = createState('a')
			const a = createMemo(() => {
				x.get()
				return 'foo'
			})
			const b = createMemo(() => {
				count++
				return a.get()
			})
			expect(b.get()).toBe('foo')
			expect(count).toBe(1)
			x.set('aa')
			x.set('aaa')
			expect(b.get()).toBe('foo')
			expect(count).toBe(1)
		})

		test('should not propagate when intermediate result is unchanged', () => {
			let count = 0
			const x = createState(42)
			const a = createMemo(() => x.get() % 2)
			const b = createMemo(() => (a.get() ? 'odd' : 'even'))
			const c = createMemo(() => {
				count++
				return `c: ${b.get()}`
			})
			expect(c.get()).toBe('c: even')
			expect(count).toBe(1)
			x.set(44)
			x.set(46)
			expect(c.get()).toBe('c: even')
			expect(count).toBe(1)
		})
	})

	describe('Diamond Graph', () => {
		test('should compute each memo only once', () => {
			let count = 0
			const x = createState('a')
			const a = createMemo(() => x.get())
			const b = createMemo(() => x.get())
			const c = createMemo(() => {
				count++
				return `${a.get()} ${b.get()}`
			})
			expect(c.get()).toBe('a a')
			expect(count).toBe(1)
			x.set('aa')
			expect(c.get()).toBe('aa aa')
			expect(count).toBe(2)
		})

		test('should compute each memo only once with tail', () => {
			let count = 0
			const x = createState('a')
			const a = createMemo(() => x.get())
			const b = createMemo(() => x.get())
			const c = createMemo(() => `${a.get()} ${b.get()}`)
			const d = createMemo(() => {
				count++
				return c.get()
			})
			expect(d.get()).toBe('a a')
			expect(count).toBe(1)
			x.set('aa')
			expect(d.get()).toBe('aa aa')
			expect(count).toBe(2)
		})

		test('should drop X->B->X updates', () => {
			let count = 0
			const x = createState(2)
			const a = createMemo(() => x.get() - 1)
			const b = createMemo(() => x.get() + a.get())
			const c = createMemo(() => {
				count++
				return `c: ${b.get()}`
			})
			expect(c.get()).toBe('c: 3')
			expect(count).toBe(1)
			x.set(4)
			expect(c.get()).toBe('c: 7')
			expect(count).toBe(2)
		})
	})

	describe('Error Handling', () => {
		test('should detect and throw for circular dependencies', () => {
			const a = createState(1)
			const b = createMemo(() => c.get() + 1)
			const c = createMemo((): number => b.get() + a.get())
			expect(() => b.get()).toThrow('[Memo] Circular dependency detected')
		})

		test('should propagate errors from computation', () => {
			const x = createState(0)
			const a = createMemo(() => {
				if (x.get() === 1) throw new Error('Computation failed')
				return 1
			})
			expect(a.get()).toBe(1)
			x.set(1)
			expect(() => a.get()).toThrow('Computation failed')
		})

		test('should allow downstream memos to recover from errors', () => {
			const x = createState(0)
			let errCount = 0
			const a = createMemo(() => {
				if (x.get() === 1) throw new Error('Computation failed')
				return 1
			})
			const b = createMemo(() => {
				try {
					return `ok: ${a.get()}`
				} catch (_e) {
					errCount++
					return 'recovered'
				}
			})

			expect(b.get()).toBe('ok: 1')
			x.set(1)
			expect(b.get()).toBe('recovered')
			expect(errCount).toBe(1)

			x.set(0)
			expect(b.get()).toBe('ok: 1')
		})
	})

	describe('options.value (prev)', () => {
		test('should pass initial value as prev to first computation', () => {
			let receivedPrev: number | undefined
			const memo = createMemo(
				prev => {
					receivedPrev = prev
					return prev + 1
				},
				{ value: 10 },
			)
			expect(memo.get()).toBe(11)
			expect(receivedPrev).toBe(10)
		})

		test('should pass undefined as prev when no initial value', () => {
			let receivedPrev: unknown = 999
			const memo = createMemo((prev: number | undefined) => {
				receivedPrev = prev
				return 42
			})
			memo.get()
			expect(receivedPrev).toBeUndefined()
		})

		test('should pass previous computed value on recomputation', () => {
			const source = createState(5)
			let receivedPrev: number | undefined
			const memo = createMemo(
				prev => {
					receivedPrev = prev
					return source.get() * 2
				},
				{ value: 0 },
			)

			expect(memo.get()).toBe(10)
			expect(receivedPrev).toBe(0)

			source.set(3)
			expect(memo.get()).toBe(6)
			expect(receivedPrev).toBe(10)
		})

		test('should work as a reducer', () => {
			const increment = createState(0)
			const sum = createMemo(
				prev => {
					const inc = increment.get()
					return inc === 0 ? prev : prev + inc
				},
				{ value: 0 },
			)

			expect(sum.get()).toBe(0)
			increment.set(5)
			expect(sum.get()).toBe(5)
			increment.set(3)
			expect(sum.get()).toBe(8)
		})

		test('should preserve prev value across errors', () => {
			const shouldError = createState(false)
			const counter = createState(1)
			const memo = createMemo(
				prev => {
					if (shouldError.get()) throw new Error('fail')
					return prev + counter.get()
				},
				{ value: 10 },
			)

			expect(memo.get()).toBe(11) // 10 + 1
			counter.set(5)
			expect(memo.get()).toBe(16) // 11 + 5

			shouldError.set(true)
			expect(() => memo.get()).toThrow('fail')

			shouldError.set(false)
			counter.set(2)
			expect(memo.get()).toBe(18) // 16 + 2
		})
	})

	describe('options.equals', () => {
		test('should use custom equality to skip propagation', () => {
			const source = createState(1)
			let downstream = 0
			const memo = createMemo(() => ({ x: source.get() % 2 }), {
				value: { x: -1 },
				equals: (a, b) => a.x === b.x,
			})
			const tail = createMemo(() => {
				downstream++
				return memo.get()
			})

			tail.get()
			expect(downstream).toBe(1)

			source.set(3) // still odd, structurally equal
			tail.get()
			expect(downstream).toBe(1)

			source.set(2) // now even, different
			tail.get()
			expect(downstream).toBe(2)
		})
	})

	describe('options.guard', () => {
		test('should validate initial value against guard', () => {
			expect(() => {
				createMemo(() => 42, {
					value: -1,
					guard: (v): v is number => typeof v === 'number' && v >= 0,
				})
			}).toThrow('[Memo] Signal value -1 is invalid')
		})

		test('should accept initial value that passes guard', () => {
			const memo = createMemo(prev => prev + 1, {
				value: 0,
				guard: (v): v is number => typeof v === 'number' && v >= 0,
			})
			expect(memo.get()).toBe(1)
		})
	})

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

		test('should throw InvalidCallbackError for async callback', () => {
			expect(() => createMemo(async () => 42)).toThrow()
		})

		test('should throw NullishSignalValueError for null initial value', () => {
			expect(() => {
				// @ts-expect-error - Testing invalid input
				createMemo(() => 42, { value: null })
			}).toThrow('[Memo] Signal value cannot be null or undefined')
		})
	})

	describe('options.watched', () => {
		test('should call watched on first effect access', () => {
			let watchedCount = 0
			const externalValue = 1

			const memo = createMemo(() => externalValue, {
				value: 0,
				watched: _invalidate => {
					watchedCount++
					return () => {}
				},
			})

			expect(watchedCount).toBe(0)

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

			expect(watchedCount).toBe(1)
			dispose()
		})

		test('should call cleanup when last effect stops watching', () => {
			let cleanedUp = false
			const externalValue = 1

			const memo = createMemo(() => externalValue, {
				value: 0,
				watched: _invalidate => {
					return () => {
						cleanedUp = true
					}
				},
			})

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

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

		test('should recompute memo when invalidate is called', () => {
			let externalValue = 10
			let computeCount = 0
			let invalidate!: () => void

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

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

			expect(observed).toBe(10)
			expect(computeCount).toBe(1)

			externalValue = 20
			invalidate()
			expect(observed).toBe(20)
			expect(computeCount).toBe(2)

			dispose()
		})

		test('should defer flush when invalidate is called inside batch', () => {
			let externalValue = 1
			let invalidate!: () => void

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

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

			expect(observed).toBe(1)

			batch(() => {
				externalValue = 2
				invalidate()
				expect(observed).toBe(1) // not yet flushed
			})
			expect(observed).toBe(2) // flushed after batch

			dispose()
		})

		test('should re-activate watched after cleanup and new effect access', () => {
			let watchedCount = 0
			const externalValue = 1

			const memo = createMemo(() => externalValue, {
				value: 0,
				watched: _invalidate => {
					watchedCount++
					return () => {}
				},
			})

			const dispose1 = createScope(() => {
				createEffect(() => {
					void memo.get()
				})
			})
			expect(watchedCount).toBe(1)
			dispose1()

			const dispose2 = createScope(() => {
				createEffect(() => {
					void memo.get()
				})
			})
			expect(watchedCount).toBe(2)
			dispose2()
		})

		test('should work with both tracked dependencies and watched', () => {
			const source = createState(1)
			let externalValue = 100
			let computeCount = 0
			let invalidate!: () => void

			const memo = createMemo(
				() => {
					computeCount++
					return source.get() + externalValue
				},
				{
					value: 0,
					watched: inv => {
						invalidate = inv
						return () => {}
					},
				},
			)

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

			expect(observed).toBe(101)
			expect(computeCount).toBe(1)

			// Tracked dependency triggers recomputation
			source.set(2)
			expect(observed).toBe(102)
			expect(computeCount).toBe(2)

			// External invalidation triggers recomputation
			externalValue = 200
			invalidate()
			expect(observed).toBe(202)
			expect(computeCount).toBe(3)

			dispose()
		})
	})
})
