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

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

// ─── Guide: Keyed Collections ────────────────────────────────────────────────

describe('Guide: Keyed Collections', () => {
	type Todo = { id: string; title: string; done: boolean }

	test('list with content-based keyConfig assigns item.id as key', () => {
		const todos = createList<Todo>(
			[
				{ id: 'a', title: 'Write docs', done: false },
				{ id: 'b', title: 'Ship release', done: true },
			],
			{ keyConfig: item => item.id },
		)
		expect(todos.keyAt(0)).toBe('a')
		expect(todos.keyAt(1)).toBe('b')
		expect(todos.byKey('a')?.get()).toEqual({
			id: 'a',
			title: 'Write docs',
			done: false,
		})
	})

	test('byKey returns the same signal reference before and after replace', () => {
		const todos = createList<Todo>(
			[{ id: 'a', title: 'Write docs', done: false }],
			{ keyConfig: item => item.id },
		)
		const before = todos.byKey('a')
		todos.replace('a', { id: 'a', title: 'Write final docs', done: false })
		expect(todos.byKey('a')).toBe(before)
		expect(todos.byKey('a')?.get().title).toBe('Write final docs')
	})

	test('replace notifies an effect reading the item signal directly', () => {
		const todos = createList<Todo>(
			[{ id: 'a', title: 'Write docs', done: false }],
			{ keyConfig: item => item.id },
		)
		const seen: string[] = []
		const itemSignal = todos.byKey('a')
		const dispose = createEffect(() => {
			seen.push(itemSignal.get().title)
		})
		todos.replace('a', { id: 'a', title: 'Write final docs', done: false })
		expect(seen).toEqual(['Write docs', 'Write final docs'])
		dispose()
	})

	test('deriveCollection maps item values to a read-only collection', () => {
		const todos = createList<Todo>(
			[
				{ id: 'a', title: 'Write docs', done: false },
				{ id: 'b', title: 'Ship release', done: true },
			],
			{ keyConfig: item => item.id },
		)
		const visibleTitles = todos.deriveCollection(item =>
			item.done ? 'archived' : item.title,
		)
		expect(visibleTitles.get()).toEqual(['Write docs', 'archived'])
	})

	test('deriveCollection updates when an item is replaced', () => {
		const todos = createList<Todo>(
			[
				{ id: 'a', title: 'Write docs', done: false },
				{ id: 'b', title: 'Ship release', done: true },
			],
			{ keyConfig: item => item.id },
		)
		const visibleTitles = todos.deriveCollection(item =>
			item.done ? 'archived' : item.title,
		)
		todos.replace('b', { id: 'b', title: 'Ship release', done: false })
		expect(visibleTitles.get()).toEqual(['Write docs', 'Ship release'])
	})

	test('sort reorders items and derived collection follows', () => {
		const todos = createList<Todo>(
			[
				{ id: 'a', title: 'Write docs', done: false },
				{ id: 'b', title: 'Audit docs build', done: false },
				{ id: 'c', title: 'Ship release', done: false },
			],
			{ keyConfig: item => item.id },
		)
		const titles = todos.deriveCollection(item => item.title)
		todos.sort((a, b) => a.title.localeCompare(b.title))
		expect(todos.get().map(t => t.title)).toEqual([
			'Audit docs build',
			'Ship release',
			'Write docs',
		])
		expect(titles.get()).toEqual([
			'Audit docs build',
			'Ship release',
			'Write docs',
		])
	})

	test('add inserts a new item and increments length', () => {
		const todos = createList<Todo>(
			[{ id: 'a', title: 'Write docs', done: false }],
			{ keyConfig: item => item.id },
		)
		todos.add({ id: 'c', title: 'Audit docs build', done: false })
		expect(todos.length).toBe(2)
		expect(todos.byKey('c')?.get().title).toBe('Audit docs build')
	})

	test('openCount memo tracks undone items across mutations', () => {
		const todos = createList<Todo>(
			[
				{ id: 'a', title: 'Write docs', done: false },
				{ id: 'b', title: 'Ship release', done: true },
			],
			{ keyConfig: item => item.id },
		)
		const openCount = createMemo(
			() => todos.get().filter(item => !item.done).length,
		)
		expect(openCount.get()).toBe(1)
		todos.replace('b', { id: 'b', title: 'Ship release', done: false })
		expect(openCount.get()).toBe(2)
	})

	test('full guide: replace + add + sort produce correct order and derived values', () => {
		const todos = createList<Todo>(
			[
				{ id: 'a', title: 'Write docs', done: false },
				{ id: 'b', title: 'Ship release', done: true },
			],
			{ keyConfig: item => item.id },
		)
		const openCount = createMemo(
			() => todos.get().filter(item => !item.done).length,
		)
		const visibleTitles = todos.deriveCollection(item =>
			item.done ? 'archived' : item.title,
		)

		todos.replace('a', { id: 'a', title: 'Write final docs', done: false })
		todos.add({ id: 'c', title: 'Audit docs build', done: false })
		todos.sort((a, b) => a.title.localeCompare(b.title))

		// After sort: 'Audit docs build' < 'Ship release' < 'Write final docs'
		expect(todos.get().map(t => t.title)).toEqual([
			'Audit docs build',
			'Ship release',
			'Write final docs',
		])
		expect(openCount.get()).toBe(2) // 'Audit docs build' and 'Write final docs' are open
		expect(visibleTitles.get()).toEqual([
			'Audit docs build',
			'archived',
			'Write final docs',
		])
	})
})

// ─── Guide: Async Data Pipelines ─────────────────────────────────────────────

describe('Guide: Async Data Pipelines', () => {
	type SearchResponse = {
		items: { id: string; title: string }[]
		total: number
	}
	const SEED: SearchResponse = { items: [], total: 0 }

	test('task with seed value enters stale state on first match', async () => {
		const query = createState('books')
		const results = createTask<SearchResponse>(
			async (_prev, abort) => {
				await wait(50)
				if (abort.aborted) return SEED
				return { items: [{ id: '1', title: query.get() }], total: 1 }
			},
			{ value: SEED },
		)
		const states: string[] = []
		const dispose = createEffect(() => {
			match(results, {
				stale: () => states.push('stale'),
				ok: data => states.push(`ok:${data.total}`),
			})
		})
		expect(states).toEqual(['stale'])
		await wait(60)
		expect(states).toEqual(['stale', 'ok:1'])
		dispose()
	})

	test('derived memo reads seed value while task is pending', async () => {
		const results = createTask<SearchResponse>(
			async () => {
				await wait(50)
				return { items: [{ id: '1', title: 'test' }], total: 42 }
			},
			{ value: SEED },
		)
		const totalPages = createMemo(() =>
			Math.max(1, Math.ceil(results.get().total / 20)),
		)
		results.get() // trigger computation
		expect(totalPages.get()).toBe(1) // seed total=0 → max(1, ceil(0/20))=1
		await wait(60)
		expect(totalPages.get()).toBe(3) // resolved total=42 → ceil(42/20)=3
	})

	test('batch prevents duplicate task run when query and page change together', async () => {
		const query = createState('books')
		const page = createState(1)
		const requestKey = createMemo(() => ({
			query: query.get().trim(),
			page: page.get(),
		}))
		let runCount = 0
		const results = createTask<SearchResponse>(
			async (_prev, abort) => {
				runCount++
				const { query: q, page: p } = requestKey.get()
				await wait(30)
				if (abort.aborted) return SEED
				return { items: [{ id: '1', title: `${q} p${p}` }], total: 1 }
			},
			{ value: SEED },
		)
		const dispose = createEffect(() => {
			match(results, {
				stale: () => {},
				ok: () => {},
			})
		})
		await wait(50) // first run completes
		const firstRunCount = runCount

		batch(() => {
			query.set('signals')
			page.set(2) // changing both ensures a meaningful batch test
		})
		await wait(60) // second run completes
		expect(runCount).toBe(firstRunCount + 1) // exactly one additional run
		expect(results.get().items[0]?.title).toBe('signals p2')
		dispose()
	})

	test('AbortSignal is triggered when dependency changes before task completes', async () => {
		const query = createState('books')
		const aborted: string[] = []
		const results = createTask<SearchResponse>(
			async (_prev, signal) => {
				const q = query.get()
				await wait(80)
				if (signal.aborted) {
					aborted.push(q)
					return SEED
				}
				return { items: [{ id: '1', title: q }], total: 1 }
			},
			{ value: SEED },
		)
		const dispose = createEffect(() => {
			match(results, {
				stale: () => {},
				ok: () => {},
			})
		})
		await wait(20) // first run ('books') is in flight
		query.set('signals') // aborts 'books' run, starts 'signals' run
		await wait(100) // 'signals' run completes 80ms after re-trigger
		expect(aborted).toContain('books')
		expect(results.get().items[0]?.title).toBe('signals')
		dispose()
	})
})

// ─── Guide: Custom Elements and External Lifecycles ──────────────────────────

describe('Guide: Custom Elements and External Lifecycles', () => {
	test('slot used as property descriptor reads from and writes to backing signal', () => {
		const source = createState('draft')
		const slot = createSlot(source)
		const target: Record<string, unknown> = {}
		Object.defineProperty(target, 'value', slot)

		expect((target as { value: string }).value).toBe('draft')
		;(target as { value: string }).value = 'published'
		expect(source.get()).toBe('published')
	})

	test('slot.replace swaps backing signal and downstream effects resubscribe', () => {
		const internalValue = createState('draft')
		const slot = createSlot(internalValue)
		const log: string[] = []
		const dispose = createEffect(() => {
			log.push(slot.get())
		})
		const controlled = createMemo(() => `status:${internalValue.get()}`)
		slot.replace(controlled)
		expect(log).toEqual(['draft', 'status:draft'])
		internalValue.set('published')
		expect(log).toEqual(['draft', 'status:draft', 'status:published'])
		dispose()
	})

	test('sensor watched callback starts lazily on first subscription and stops on dispose', () => {
		let started = false
		let stopped = false
		let push!: (v: boolean) => void
		const sensor = createSensor<boolean>(set => {
			started = true
			push = set
			push(true)
			return () => {
				stopped = true
			}
		})
		expect(started).toBe(false) // lazy — not started until first subscriber
		const log: boolean[] = []
		const dispose = createEffect(() => {
			log.push(sensor.get())
		})
		expect(started).toBe(true)
		expect(log).toEqual([true])
		push(false)
		expect(log).toEqual([true, false])
		expect(stopped).toBe(false)
		dispose()
		expect(stopped).toBe(true) // cleanup runs when last subscriber unsubscribes
	})

	test('simulated custom element: createScope root:true is sole lifecycle authority', () => {
		const label = createState('idle')
		const log: string[] = []

		class MockElement {
			#dispose?: (() => void) | undefined
			textContent = ''

			connectedCallback() {
				this.#dispose = createScope(
					() => {
						createEffect(() => {
							this.textContent = label.get()
							log.push(this.textContent)
						})
					},
					{ root: true },
				)
			}

			disconnectedCallback() {
				this.#dispose?.()
				this.#dispose = undefined
			}
		}

		const el = new MockElement()
		el.connectedCallback()
		expect(log).toEqual(['idle'])

		label.set('active')
		expect(log).toEqual(['idle', 'active'])

		el.disconnectedCallback()
		label.set('gone')
		expect(log).toEqual(['idle', 'active']) // scope disposed — no more updates

		el.connectedCallback() // reconnect: fresh scope picks up current label value
		expect(log).toEqual(['idle', 'active', 'gone'])

		el.disconnectedCallback()
	})
})
