import { describe, expect, spyOn, test } from "bun:test"

import type { ActionDefinition, ContextEntry, ContextKey, FeedItem, FeedSource } from "./index"

import { FeedEngine } from "./feed-engine"
import { Context, TimeRelevance, UnknownActionError, contextKey } from "./index"

// No-op action methods for test sources
const noActions = {
	async listActions(): Promise<Record<string, ActionDefinition>> {
		return {}
	},
	async executeAction(actionId: string): Promise<void> {
		throw new UnknownActionError(actionId)
	},
}

// =============================================================================
// CONTEXT KEYS
// =============================================================================

interface Location {
	lat: number
	lng: number
}

interface Weather {
	temperature: number
	condition: string
}

const LocationKey: ContextKey<Location> = contextKey("location")
const WeatherKey: ContextKey<Weather> = contextKey("weather")

// =============================================================================
// FEED ITEMS
// =============================================================================

type WeatherFeedItem = FeedItem<"weather", { temperature: number; condition: string }>
type AlertFeedItem = FeedItem<"alert", { message: string }>

// =============================================================================
// TEST HELPERS
// =============================================================================

interface SimulatedLocationSource extends FeedSource {
	simulateUpdate(location: Location): void
}

function createLocationSource(): SimulatedLocationSource {
	let callback: ((entries: readonly ContextEntry[]) => void) | null = null
	let currentLocation: Location = { lat: 0, lng: 0 }

	return {
		id: "location",
		...noActions,

		onContextUpdate(cb) {
			callback = cb
			return () => {
				callback = null
			}
		},

		async fetchContext() {
			return [[LocationKey, currentLocation]]
		},

		simulateUpdate(location: Location) {
			currentLocation = location
			callback?.([[LocationKey, location]])
		},
	}
}

function createWeatherSource(
	fetchWeather: (location: Location) => Promise<Weather> = async () => ({
		temperature: 20,
		condition: "sunny",
	}),
): FeedSource<WeatherFeedItem> {
	return {
		id: "weather",
		dependencies: ["location"],
		...noActions,

		async fetchContext(context) {
			const location = context.get(LocationKey)
			if (!location) return null

			const weather = await fetchWeather(location)
			return [[WeatherKey, weather]]
		},

		async fetchItems(context) {
			const weather = context.get(WeatherKey)
			if (!weather) return []

			return [
				{
					id: `weather-${Date.now()}`,
					sourceId: "weather",
					type: "weather",
					timestamp: new Date(),
					data: {
						temperature: weather.temperature,
						condition: weather.condition,
					},
					signals: { urgency: 0.5, timeRelevance: TimeRelevance.Ambient },
				},
			]
		},
	}
}

function createAlertSource(): FeedSource<AlertFeedItem> {
	return {
		id: "alert",
		dependencies: ["weather"],
		...noActions,

		async fetchContext() {
			return null
		},

		async fetchItems(context) {
			const weather = context.get(WeatherKey)
			if (!weather) return []

			if (weather.condition === "storm") {
				return [
					{
						id: "alert-storm",
						sourceId: "alert",
						type: "alert",
						timestamp: new Date(),
						data: { message: "Storm warning!" },
						signals: { urgency: 1.0, timeRelevance: TimeRelevance.Imminent },
					},
				]
			}

			return []
		},
	}
}

async function waitForCondition(predicate: () => boolean, timeoutMs = 2_000): Promise<void> {
	const deadline = Date.now() + timeoutMs
	while (!predicate()) {
		if (Date.now() > deadline) {
			throw new Error("Timed out waiting for condition")
		}
		await new Promise((resolve) => setTimeout(resolve, 10))
	}
}

// =============================================================================
// TESTS
// =============================================================================

describe("FeedEngine", () => {
	describe("registration", () => {
		test("registers sources", () => {
			const engine = new FeedEngine()
			const location = createLocationSource()

			engine.register(location)

			// Can refresh without error
			expect(engine.refresh()).resolves.toBeDefined()
		})

		test("unregisters sources", async () => {
			const engine = new FeedEngine()
			const location = createLocationSource()

			engine.register(location)
			engine.unregister("location")

			const result = await engine.refresh()
			expect(result.items).toHaveLength(0)
		})

		test("allows chained registration", () => {
			const engine = new FeedEngine()
				.register(createLocationSource())
				.register(createWeatherSource())
				.register(createAlertSource())

			expect(engine.refresh()).resolves.toBeDefined()
		})

		test("register invalidates feed cache", async () => {
			const location = createLocationSource()
			const engine = new FeedEngine().register(location)

			await engine.refresh()
			expect(engine.lastFeed()).not.toBeNull()

			engine.register(createWeatherSource())

			expect(engine.lastFeed()).toBeNull()
		})

		test("unregister invalidates feed cache", async () => {
			const location = createLocationSource()
			const weather = createWeatherSource()
			const engine = new FeedEngine().register(location).register(weather)

			await engine.refresh()
			expect(engine.lastFeed()).not.toBeNull()

			engine.unregister("weather")

			expect(engine.lastFeed()).toBeNull()
		})
	})

	describe("graph validation", () => {
		test("throws on missing dependency", async () => {
			const engine = new FeedEngine()
			const orphan: FeedSource = {
				id: "orphan",
				dependencies: ["nonexistent"],
				...noActions,
				async fetchContext() {
					return null
				},
			}

			engine.register(orphan)

			await expect(engine.refresh()).rejects.toThrow(
				'Source "orphan" depends on "nonexistent" which is not registered',
			)
		})

		test("throws on circular dependency", async () => {
			const engine = new FeedEngine()
			const a: FeedSource = {
				id: "a",
				dependencies: ["b"],
				...noActions,
				async fetchContext() {
					return null
				},
			}
			const b: FeedSource = {
				id: "b",
				dependencies: ["a"],
				...noActions,
				async fetchContext() {
					return null
				},
			}

			engine.register(a).register(b)

			await expect(engine.refresh()).rejects.toThrow("Circular dependency detected: a → b → a")
		})

		test("throws on longer cycles", async () => {
			const engine = new FeedEngine()
			const a: FeedSource = {
				id: "a",
				dependencies: ["c"],
				...noActions,
				async fetchContext() {
					return null
				},
			}
			const b: FeedSource = {
				id: "b",
				dependencies: ["a"],
				...noActions,
				async fetchContext() {
					return null
				},
			}
			const c: FeedSource = {
				id: "c",
				dependencies: ["b"],
				...noActions,
				async fetchContext() {
					return null
				},
			}

			engine.register(a).register(b).register(c)

			await expect(engine.refresh()).rejects.toThrow("Circular dependency detected")
		})
	})

	describe("refresh", () => {
		test("runs fetchContext in dependency order", async () => {
			const order: string[] = []

			const location: FeedSource = {
				id: "location",
				...noActions,
				async fetchContext() {
					order.push("location")
					return [[LocationKey, { lat: 51.5, lng: -0.1 }]]
				},
			}

			const weather: FeedSource = {
				id: "weather",
				dependencies: ["location"],
				...noActions,
				async fetchContext(ctx) {
					order.push("weather")
					const loc = ctx.get(LocationKey)
					expect(loc).toBeDefined()
					return [[WeatherKey, { temperature: 20, condition: "sunny" }]]
				},
			}

			const engine = new FeedEngine().register(weather).register(location)

			await engine.refresh()

			expect(order).toEqual(["location", "weather"])
		})

		test("accumulates context across sources", async () => {
			const location = createLocationSource()
			location.simulateUpdate({ lat: 51.5, lng: -0.1 })

			const weather = createWeatherSource()

			const engine = new FeedEngine().register(location).register(weather)

			const { context } = await engine.refresh()

			expect(context.get(LocationKey)).toEqual({
				lat: 51.5,
				lng: -0.1,
			})
			expect(context.get(WeatherKey)).toEqual({
				temperature: 20,
				condition: "sunny",
			})
		})

		test("collects items from all sources", async () => {
			const location = createLocationSource()
			location.simulateUpdate({ lat: 51.5, lng: -0.1 })

			const weather = createWeatherSource()

			const engine = new FeedEngine().register(location).register(weather)

			const { items } = await engine.refresh()

			expect(items).toHaveLength(1)
			expect(items[0]!.type).toBe("weather")
		})

		test("returns items in source graph order (no engine-level sorting)", async () => {
			const location = createLocationSource()
			location.simulateUpdate({ lat: 51.5, lng: -0.1 })

			const weather = createWeatherSource(async () => ({
				temperature: 15,
				condition: "storm",
			}))

			const alert = createAlertSource()

			const engine = new FeedEngine().register(location).register(weather).register(alert)

			const { items } = await engine.refresh()

			expect(items).toHaveLength(2)
			// Items returned in topological order (weather before alert)
			expect(items[0]!.type).toBe("weather")
			expect(items[1]!.type).toBe("alert")
			// Signals are preserved for post-processors to consume
			expect(items[0]!.signals?.urgency).toBe(0.5)
			expect(items[1]!.signals?.urgency).toBe(1.0)
		})

		test("handles missing upstream context gracefully", async () => {
			const location: FeedSource = {
				id: "location",
				...noActions,
				async fetchContext() {
					return null // No location available
				},
			}

			const weather = createWeatherSource()

			const engine = new FeedEngine().register(location).register(weather)

			const { context, items } = await engine.refresh()

			expect(context.get(WeatherKey)).toBeUndefined()
			expect(items).toHaveLength(0)
		})

		test("captures errors from fetchContext", async () => {
			const failing: FeedSource = {
				id: "failing",
				...noActions,
				async fetchContext() {
					throw new Error("Context fetch failed")
				},
			}

			const engine = new FeedEngine().register(failing)

			const { errors } = await engine.refresh()

			expect(errors).toHaveLength(1)
			expect(errors[0]!.sourceId).toBe("failing")
			expect(errors[0]!.error.message).toBe("Context fetch failed")
		})

		test("captures errors from fetchItems", async () => {
			const failing: FeedSource = {
				id: "failing",
				...noActions,
				async fetchContext() {
					return null
				},
				async fetchItems() {
					throw new Error("Items fetch failed")
				},
			}

			const engine = new FeedEngine().register(failing)

			const { errors } = await engine.refresh()

			expect(errors).toHaveLength(1)
			expect(errors[0]!.sourceId).toBe("failing")
			expect(errors[0]!.error.message).toBe("Items fetch failed")
		})

		test("continues after source error", async () => {
			const failing: FeedSource = {
				id: "failing",
				...noActions,
				async fetchContext() {
					throw new Error("Failed")
				},
			}

			const working: FeedSource = {
				id: "working",
				...noActions,
				async fetchContext() {
					return null
				},
				async fetchItems() {
					return [
						{
							id: "item-1",
							sourceId: "working",
							type: "test",
							priority: 0.5,
							timestamp: new Date(),
							data: {},
						},
					]
				},
			}

			const engine = new FeedEngine().register(failing).register(working)

			const { items, errors } = await engine.refresh()

			expect(errors).toHaveLength(1)
			expect(items).toHaveLength(1)
		})
	})

	describe("currentContext", () => {
		test("returns initial context before refresh", () => {
			const engine = new FeedEngine()

			const context = engine.currentContext()

			expect(context.time).toBeInstanceOf(Date)
		})

		test("returns accumulated context after refresh", async () => {
			const location = createLocationSource()
			location.simulateUpdate({ lat: 51.5, lng: -0.1 })

			const engine = new FeedEngine().register(location)

			await engine.refresh()

			const context = engine.currentContext()
			expect(context.get(LocationKey)).toEqual({
				lat: 51.5,
				lng: -0.1,
			})
		})
	})

	describe("subscribe", () => {
		test("returns unsubscribe function", () => {
			const engine = new FeedEngine()
			let callCount = 0

			const unsubscribe = engine.subscribe(() => {
				callCount++
			})

			unsubscribe()

			// Subscriber should not be called after unsubscribe
			expect(callCount).toBe(0)
		})
	})

	describe("reactive updates", () => {
		test("start subscribes to onContextUpdate", async () => {
			const location = createLocationSource()
			const weather = createWeatherSource()

			const engine = new FeedEngine().register(location).register(weather)

			const results: Array<{ items: FeedItem[] }> = []
			engine.subscribe((result) => {
				results.push({ items: result.items })
			})

			engine.start()

			// Simulate location update
			location.simulateUpdate({ lat: 51.5, lng: -0.1 })

			// Wait for async refresh
			await new Promise((resolve) => setTimeout(resolve, 50))

			expect(results.length).toBeGreaterThan(0)
			expect(results[0]!.items[0]!.type).toBe("weather")
		})

		test("stop unsubscribes from all sources", async () => {
			const location = createLocationSource()

			const engine = new FeedEngine().register(location)

			let callCount = 0
			engine.subscribe(() => {
				callCount++
			})

			engine.start()
			engine.stop()

			// Simulate update after stop
			location.simulateUpdate({ lat: 1, lng: 1 })

			await new Promise((resolve) => setTimeout(resolve, 50))

			expect(callCount).toBe(0)
		})

		test("start is idempotent", () => {
			const location = createLocationSource()
			const engine = new FeedEngine().register(location)

			// Should not throw or double-subscribe
			engine.start()
			engine.start()
			engine.stop()
		})
	})

	describe("executeAction", () => {
		test("routes action to correct source", async () => {
			let receivedAction = ""
			let receivedParams: unknown = {}

			const source: FeedSource = {
				id: "test-source",
				async listActions() {
					return {
						"do-thing": { id: "do-thing" },
					}
				},
				async executeAction(actionId, params) {
					receivedAction = actionId
					receivedParams = params
				},
				async fetchContext() {
					return null
				},
			}

			const engine = new FeedEngine().register(source)
			await engine.executeAction("test-source", "do-thing", { key: "value" })

			expect(receivedAction).toBe("do-thing")
			expect(receivedParams).toEqual({ key: "value" })
		})

		test("throws for unknown source", async () => {
			const engine = new FeedEngine()

			await expect(engine.executeAction("nonexistent", "action", {})).rejects.toThrow(
				"Source not found: nonexistent",
			)
		})

		test("throws for unknown action on source", async () => {
			const source: FeedSource = {
				id: "test-source",
				...noActions,
				async fetchContext() {
					return null
				},
			}

			const engine = new FeedEngine().register(source)

			await expect(engine.executeAction("test-source", "nonexistent", {})).rejects.toThrow(
				'Action "nonexistent" not found on source "test-source"',
			)
		})
	})

	describe("listActions", () => {
		test("returns actions for a specific source", async () => {
			const source: FeedSource = {
				id: "test-source",
				async listActions() {
					return {
						"action-1": { id: "action-1" },
						"action-2": { id: "action-2" },
					}
				},
				async executeAction() {},
				async fetchContext() {
					return null
				},
			}

			const engine = new FeedEngine().register(source)
			const actions = await engine.listActions("test-source")

			expect(Object.keys(actions)).toEqual(["action-1", "action-2"])
		})

		test("throws for unknown source", async () => {
			const engine = new FeedEngine()

			await expect(engine.listActions("nonexistent")).rejects.toThrow(
				"Source not found: nonexistent",
			)
		})

		test("throws on mismatched action ID", async () => {
			const source: FeedSource = {
				id: "bad-source",
				async listActions() {
					return {
						"correct-key": { id: "wrong-id" },
					}
				},
				async executeAction() {},
				async fetchContext() {
					return null
				},
			}

			const engine = new FeedEngine().register(source)

			await expect(engine.listActions("bad-source")).rejects.toThrow(
				'Action ID mismatch on source "bad-source"',
			)
		})
	})

	describe("lastFeed", () => {
		test("returns null before any refresh", () => {
			const engine = new FeedEngine()

			expect(engine.lastFeed()).toBeNull()
		})

		test("returns cached result after refresh", async () => {
			const location = createLocationSource()
			location.simulateUpdate({ lat: 51.5, lng: -0.1 })

			const weather = createWeatherSource()
			const engine = new FeedEngine().register(location).register(weather)

			const refreshResult = await engine.refresh()

			const cached = engine.lastFeed()
			expect(cached).not.toBeNull()
			expect(cached!.items).toEqual(refreshResult.items)
			expect(cached!.context).toEqual(refreshResult.context)
		})

		test("returns null after TTL expires", async () => {
			const engine = new FeedEngine({ cacheTtlMs: 50 })
			const location = createLocationSource()
			location.simulateUpdate({ lat: 51.5, lng: -0.1 })

			engine.register(location)
			await engine.refresh()

			expect(engine.lastFeed()).not.toBeNull()

			await new Promise((resolve) => setTimeout(resolve, 60))

			expect(engine.lastFeed()).toBeNull()
		})

		test("defaults to 5 minute TTL", async () => {
			const engine = new FeedEngine()
			const location = createLocationSource()
			location.simulateUpdate({ lat: 51.5, lng: -0.1 })

			engine.register(location)
			await engine.refresh()

			// Should still be cached immediately
			expect(engine.lastFeed()).not.toBeNull()
		})

		test("refresh always fetches from sources", async () => {
			let fetchCount = 0
			const source: FeedSource = {
				id: "counter",
				...noActions,
				async fetchContext() {
					fetchCount++
					return null
				},
			}

			const engine = new FeedEngine().register(source)

			await engine.refresh()
			await engine.refresh()
			await engine.refresh()

			expect(fetchCount).toBe(3)
		})

		test("reactive context update refreshes cache", async () => {
			const location = createLocationSource()
			const weather = createWeatherSource()

			const engine = new FeedEngine({ cacheTtlMs: 5000 }).register(location).register(weather)

			engine.start()

			// Simulate location update which triggers reactive refresh
			location.simulateUpdate({ lat: 51.5, lng: -0.1 })

			// Wait for async reactive refresh to complete
			await new Promise((resolve) => setTimeout(resolve, 50))

			const cached = engine.lastFeed()
			expect(cached).not.toBeNull()
			expect(cached!.items.length).toBeGreaterThan(0)

			engine.stop()
		})

		test("reactive item update refreshes cache", async () => {
			let itemUpdateCallback: ((items: FeedItem[]) => void) | null = null

			const source: FeedSource = {
				id: "reactive-items",
				...noActions,
				async fetchContext() {
					return null
				},
				async fetchItems() {
					return [
						{
							id: "item-1",
							sourceId: "reactive-items",
							type: "test",
							priority: 0.5,
							timestamp: new Date(),
							data: {},
						},
					]
				},
				onItemsUpdate(callback) {
					itemUpdateCallback = callback
					return () => {
						itemUpdateCallback = null
					}
				},
			}

			const engine = new FeedEngine().register(source)
			engine.start()

			// Trigger item update
			itemUpdateCallback!([])

			// Wait for async refresh
			await new Promise((resolve) => setTimeout(resolve, 50))

			const cached = engine.lastFeed()
			expect(cached).not.toBeNull()
			expect(cached!.items).toHaveLength(1)

			engine.stop()
		})

		test("TTL resets after reactive update", async () => {
			let now = 1_000
			const nowSpy = spyOn(Date, "now").mockImplementation(() => now)
			const location = createLocationSource()
			const weather = createWeatherSource()

			const engine = new FeedEngine({ cacheTtlMs: 100 }).register(location).register(weather)

			try {
				engine.start()

				// Initial reactive update
				location.simulateUpdate({ lat: 51.5, lng: -0.1 })
				await new Promise((resolve) => setTimeout(resolve, 50))

				expect(engine.lastFeed()).not.toBeNull()

				// Move past the original TTL, then trigger another update to reset it.
				now += 120
				location.simulateUpdate({ lat: 52.0, lng: -0.2 })
				await new Promise((resolve) => setTimeout(resolve, 50))

				// Should still be cached because TTL was reset by second update.
				expect(engine.lastFeed()).not.toBeNull()

				engine.stop()
			} finally {
				engine.stop()
				nowSpy.mockRestore()
			}
		})

		test("cacheTtlMs is configurable", async () => {
			const engine = new FeedEngine({ cacheTtlMs: 30 })
			const location = createLocationSource()
			location.simulateUpdate({ lat: 51.5, lng: -0.1 })

			engine.register(location)
			await engine.refresh()

			expect(engine.lastFeed()).not.toBeNull()

			await new Promise((resolve) => setTimeout(resolve, 40))

			expect(engine.lastFeed()).toBeNull()
		})

		test("auto-refreshes on TTL interval after start", async () => {
			let fetchCount = 0
			const source: FeedSource = {
				id: "counter",
				...noActions,
				async fetchContext() {
					fetchCount++
					return null
				},
				async fetchItems() {
					return [
						{
							id: `item-${fetchCount}`,
							sourceId: "counter",
							type: "test",
							priority: 0.5,
							timestamp: new Date(),
							data: {},
						},
					]
				},
			}

			const engine = new FeedEngine({ cacheTtlMs: 20 }).register(source)
			await engine.refresh()

			expect(fetchCount).toBe(1)

			try {
				engine.start()

				await waitForCondition(() => fetchCount >= 2)

				expect(fetchCount).toBeGreaterThanOrEqual(2)
				expect(engine.lastFeed()).not.toBeNull()
			} finally {
				engine.stop()
			}
		})

		test("stop cancels periodic refresh", async () => {
			let fetchCount = 0
			const source: FeedSource = {
				id: "counter",
				...noActions,
				async fetchContext() {
					fetchCount++
					return null
				},
			}

			const engine = new FeedEngine({ cacheTtlMs: 50 }).register(source)
			engine.start()
			engine.stop()

			const countAfterStop = fetchCount

			// Wait past TTL
			await new Promise((resolve) => setTimeout(resolve, 80))

			// No additional fetches after stop
			expect(fetchCount).toBe(countAfterStop)
		})

		test("reactive update resets periodic refresh timer", async () => {
			let fetchCount = 0
			const location = createLocationSource()
			const countingWeather: FeedSource<WeatherFeedItem> = {
				id: "weather",
				dependencies: ["location"],
				...noActions,
				async fetchContext(ctx) {
					fetchCount++
					const loc = ctx.get(LocationKey)
					if (!loc) return null
					return [[WeatherKey, { temperature: 20, condition: "sunny" }]]
				},
				async fetchItems(ctx) {
					const weather = ctx.get(WeatherKey)
					if (!weather) return []
					return [
						{
							id: `weather-${Date.now()}`,
							sourceId: "weather",
							type: "weather",
							priority: 0.5,
							timestamp: new Date(),
							data: { temperature: weather.temperature, condition: weather.condition },
						},
					]
				},
			}

			const engine = new FeedEngine({ cacheTtlMs: 10_000 })
				.register(location)
				.register(countingWeather)
			const clearTimeoutSpy = spyOn(globalThis, "clearTimeout")

			try {
				engine.start()

				const countBeforeUpdate = fetchCount
				location.simulateUpdate({ lat: 51.5, lng: -0.1 })
				await waitForCondition(() => fetchCount > countBeforeUpdate && engine.lastFeed() !== null)

				// Reactive updates refresh the cache and reset the pending periodic timer.
				expect(fetchCount).toBeGreaterThan(countBeforeUpdate)
				expect(clearTimeoutSpy).toHaveBeenCalled()
			} finally {
				engine.stop()
				clearTimeoutSpy.mockRestore()
			}
		})
	})

	describe("invalidateCache", () => {
		test("clears cached result", async () => {
			const location = createLocationSource()
			const engine = new FeedEngine().register(location)

			await engine.refresh()
			expect(engine.lastFeed()).not.toBeNull()

			engine.invalidateCache()

			expect(engine.lastFeed()).toBeNull()
		})

		test("is safe to call when no cache exists", () => {
			const engine = new FeedEngine()

			expect(() => engine.invalidateCache()).not.toThrow()
			expect(engine.lastFeed()).toBeNull()
		})
	})

	describe("isStarted", () => {
		test("returns false before start", () => {
			const engine = new FeedEngine()

			expect(engine.isStarted()).toBe(false)
		})

		test("returns true after start", () => {
			const location = createLocationSource()
			const engine = new FeedEngine().register(location)

			engine.start()

			expect(engine.isStarted()).toBe(true)

			engine.stop()
		})

		test("returns false after stop", () => {
			const location = createLocationSource()
			const engine = new FeedEngine().register(location)

			engine.start()
			engine.stop()

			expect(engine.isStarted()).toBe(false)
		})
	})
})
