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

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

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

/* === Tests === */

describe('Task', () => {
	describe('createTask', () => {
		test('should resolve async computation', async () => {
			const task = createTask(
				async () => {
					await wait(50)
					return 42
				},
				{ value: 0 },
			)
			expect(task.get()).toBe(0)
			await wait(60)
			expect(task.get()).toBe(42)
		})

		test('should have Symbol.toStringTag of "Task"', () => {
			const task = createTask(async () => 1, { value: 0 })
			expect(task[Symbol.toStringTag]).toBe('Task')
		})

		test('should throw UnsetSignalValueError before resolution with no initial value', () => {
			const task = createTask(async () => {
				await wait(50)
				return 42
			})
			expect(() => task.get()).toThrow(UnsetSignalValueError)
		})
	})

	describe('isTask', () => {
		test('should identify task signals', () => {
			expect(isTask(createTask(async () => 1, { value: 0 }))).toBe(true)
		})

		test('should return false for non-task values', () => {
			expect(isTask(42)).toBe(false)
			expect(isTask(null)).toBe(false)
			expect(isTask({})).toBe(false)
			expect(isMemo(createTask(async () => 1, { value: 0 }))).toBe(false)
		})
	})

	describe('isPending', () => {
		test('should return true while computation is in-flight', async () => {
			const task = createTask(
				async () => {
					await wait(50)
					return 42
				},
				{ value: 0 },
			)
			task.get() // trigger computation
			expect(task.isPending()).toBe(true)
			await wait(60)
			task.get() // read resolved value
			expect(task.isPending()).toBe(false)
		})

		test('should return false before first get()', () => {
			const task = createTask(async () => 42, { value: 0 })
			expect(task.isPending()).toBe(false)
		})
	})

	describe('abort', () => {
		test('should abort the current computation', async () => {
			let completed = false
			const task = createTask(
				async (_prev, signal) => {
					await wait(50)
					if (!signal.aborted) completed = true
					return 42
				},
				{ value: 0 },
			)
			task.get() // trigger computation
			expect(task.isPending()).toBe(true)
			task.abort()
			expect(task.isPending()).toBe(false)
			await wait(60)
			expect(completed).toBe(false)
		})
	})

	describe('Dependency Tracking', () => {
		test('should re-execute when dependencies change', async () => {
			const source = createState(1)
			const task = createTask(
				async () => {
					const val = source.get() // dependency tracked before await
					await wait(50)
					return val * 2
				},
				{ value: 0 },
			)

			let result = 0
			createEffect(() => {
				result = task.get()
			})
			expect(result).toBe(0)
			await wait(60)
			expect(result).toBe(2)

			source.set(5)
			await wait(60)
			expect(result).toBe(10)
		})

		test('should work with downstream memos', async () => {
			const status = createState('pending')
			const task = createTask(async () => {
				await wait(50)
				status.set('success')
				return 42
			})
			const derived = createMemo(() => {
				try {
					return task.get() + 1
				} catch {
					return 0
				}
			})
			expect(derived.get()).toBe(0)
			expect(status.get()).toBe('pending')
			await wait(60)
			expect(derived.get()).toBe(43)
			expect(status.get()).toBe('success')
		})

		test('should run tasks in parallel without waterfalls', async () => {
			const a = createTask(
				async () => {
					await wait(80)
					return 10
				},
				{ value: 0 },
			)
			const b = createTask(
				async () => {
					await wait(80)
					return 20
				},
				{ value: 0 },
			)
			const sum = createMemo(() => a.get() + b.get(), { value: 0 })
			expect(sum.get()).toBe(0)
			await wait(90)
			expect(sum.get()).toBe(30)
		})
	})

	describe('AbortSignal', () => {
		test('should signal abort when dependency changes during computation', async () => {
			const source = createState(1)
			let wasAborted = false
			const task = createTask(
				async (_prev, signal) => {
					const val = source.get()
					await wait(100)
					if (signal.aborted) wasAborted = true
					return val
				},
				{ value: 0 },
			)

			task.get() // start computation
			await wait(10)
			source.set(2) // change dependency mid-flight

			await wait(110)
			expect(wasAborted).toBe(true)
		})

		test('should coalesce multiple rapid changes into one recomputation', async () => {
			const source = createState(1)
			let computationCount = 0
			const task = createTask(
				async () => {
					computationCount++
					await wait(100)
					return source.get()
				},
				{ value: 0 },
			)

			task.get()
			expect(computationCount).toBe(1)

			source.set(2)
			source.set(3)
			source.set(4)
			await wait(210)

			expect(task.get()).toBe(4)
			expect(computationCount).toBe(1)
		})
	})

	describe('Error Handling', () => {
		test('should propagate async errors on get()', async () => {
			const task = createTask(
				async () => {
					await wait(50)
					throw new Error('async failure')
				},
				{ value: 0 },
			)
			task.get()
			await wait(60)
			expect(() => task.get()).toThrow('async failure')
		})

		test('should recover from errors when dependency changes', async () => {
			const source = createState(1)
			const task = createTask(
				async () => {
					const value = source.get()
					await wait(50)
					if (value === 2) throw new Error('bad value')
					return value
				},
				{ value: 0 },
			)

			task.get()
			await wait(60)
			expect(task.get()).toBe(1)

			source.set(2)
			task.get()
			await wait(60)
			expect(() => task.get()).toThrow('bad value')

			source.set(3)
			task.get()
			await wait(60)
			expect(task.get()).toBe(3)
		})
	})

	describe('options.value (prev)', () => {
		test('should return initial value before resolution', () => {
			const task = createTask(
				async () => {
					await wait(50)
					return 42
				},
				{ value: 10 },
			)
			expect(task.get()).toBe(10)
		})

		test('should pass initial value as prev to first computation', async () => {
			let receivedPrev: number | undefined
			const task = createTask(
				async prev => {
					receivedPrev = prev
					await wait(50)
					return prev + 5
				},
				{ value: 10 },
			)

			expect(task.get()).toBe(10)
			await wait(60)
			expect(task.get()).toBe(15)
			expect(receivedPrev).toBe(10)
		})

		test('should pass previous resolved value on recomputation', async () => {
			const source = createState(1)
			const receivedPrevs: number[] = []
			const task = createTask(
				async prev => {
					const val = source.get() // dependency tracked before await
					receivedPrevs.push(prev)
					await wait(50)
					return val + prev
				},
				{ value: 0 },
			)

			let result = 0
			createEffect(() => {
				result = task.get()
			})
			await wait(60)
			expect(result).toBe(1) // 0 + 1

			source.set(2)
			await wait(60)
			expect(result).toBe(3) // 1 + 2
			expect(receivedPrevs).toEqual([0, 1])
		})
	})

	describe('options.equals', () => {
		test('should use custom equality to skip propagation after resolution', async () => {
			const source = createState(1)
			let effectCount = 0
			const task = createTask(
				async () => {
					const val = source.get() // dependency tracked before await
					await wait(50)
					return { x: val % 2 }
				},
				{
					value: { x: -1 },
					equals: (a, b) => a.x === b.x,
				},
			)

			createEffect(() => {
				task.get()
				effectCount++
			})
			await wait(60) // first resolution: { x: 1 }

			source.set(3) // still odd — result will be { x: 1 }, structurally equal
			await wait(60)
			const countAfterEqual = effectCount

			source.set(2) // now even — result will be { x: 0 }, different
			await wait(60)

			// After the structurally different result resolves, effect should run again
			expect(effectCount).toBeGreaterThan(countAfterEqual)
		})
	})

	describe('options.guard', () => {
		test('should validate initial value against guard', () => {
			expect(() => {
				createTask(async () => 42, {
					// @ts-expect-error - Testing invalid input
					value: 'foo',
					guard: (v): v is number => typeof v === 'number',
				})
			}).toThrow('[Task] Signal value "foo" is invalid')
		})

		test('should accept initial value that passes guard', () => {
			const task = createTask(async () => 42, {
				value: 10,
				guard: (v): v is number => typeof v === 'number',
			})
			expect(task.get()).toBe(10)
		})
	})

	describe('Input Validation', () => {
		test('should throw InvalidCallbackError for sync callback', () => {
			expect(() => {
				// @ts-expect-error - Testing invalid input
				createTask((_a: unknown) => 42)
			}).toThrow('[Task] Callback (_a) => 42 is invalid')
		})

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

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

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

			const task = createTask(
				async () => {
					await wait(10)
					return 1
				},
				{
					value: 0,
					watched: _invalidate => {
						watchedCount++
						return () => {}
					},
				},
			)

			expect(watchedCount).toBe(0)

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

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

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

			const task = createTask(
				async () => {
					await wait(10)
					return 1
				},
				{
					value: 0,
					watched: _invalidate => {
						return () => {
							cleanedUp = true
						}
					},
				},
			)

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

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

		test('should re-execute task when invalidate is called', async () => {
			let externalValue = 10
			let computeCount = 0
			let invalidate!: () => void

			const task = createTask(
				async () => {
					computeCount++
					await wait(10)
					return externalValue
				},
				{
					value: 0,
					watched: inv => {
						invalidate = inv
						return () => {}
					},
				},
			)

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

			await wait(20)
			expect(observed).toBe(10)
			expect(computeCount).toBe(1)

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

			dispose()
		})

		test('should abort in-flight task when invalidate is called', async () => {
			let wasAborted = false
			let invalidate!: () => void

			const task = createTask(
				async (_prev, signal) => {
					await wait(100)
					if (signal.aborted) wasAborted = true
					return 1
				},
				{
					value: 0,
					watched: inv => {
						invalidate = inv
						return () => {}
					},
				},
			)

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

			await wait(10) // task is in-flight
			invalidate() // should trigger re-execution, aborting the current one
			await wait(110)
			expect(wasAborted).toBe(true)

			dispose()
		})
	})
})
