/**
 * A Map that automatically expires entries after a configurable TTL.
 *
 * Expiry is lazy (checked on `get`/`has`) plus a periodic background sweep
 * that prevents unbounded memory growth in write-heavy workloads.
 *
 * The sweep timer is `unref()`-ed so it never keeps the Node.js process alive
 * on its own — safe to use as a module-level singleton.
 *
 * Call `dispose()` to stop the background timer when the map is no longer needed
 * (e.g. in service `onApplicationShutdown` hooks or test `afterAll` blocks).
 */
export class TtlMap<K, V> {
	private readonly store = new Map<K, { value: V; expiresAt: number }>();

	private sweepTimer: NodeJS.Timeout | null = null;

	/**
	 * @param ttlMs          Time-to-live for each entry in milliseconds.
	 * @param sweepIntervalMs How often to run the background sweep (defaults to `ttlMs`).
	 *                        Set to `0` to disable the background sweep entirely.
	 * @param onExpire        Called after an entry is removed because its TTL elapsed.
	 */
	constructor(
		private readonly ttlMs: number,
		sweepIntervalMs: number = ttlMs,
		private readonly onExpire?: (value: V) => void,
	) {
		if (sweepIntervalMs > 0) {
			this.sweepTimer = setInterval(() => this.sweep(), sweepIntervalMs).unref();
		}
	}

	private deleteIfExpired(
		key: K,
		entry: { value: V; expiresAt: number },
		now = Date.now(),
	): boolean {
		if (now <= entry.expiresAt) return false;
		if (this.store.delete(key)) this.onExpire?.(entry.value);
		return true;
	}

	set(key: K, value: V): this {
		this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs });
		return this;
	}

	get(key: K): V | undefined {
		const entry = this.store.get(key);
		if (!entry) return undefined;
		if (this.deleteIfExpired(key, entry)) return undefined;
		return entry.value;
	}

	has(key: K): boolean {
		const entry = this.store.get(key);
		return entry !== undefined && !this.deleteIfExpired(key, entry);
	}

	/**
	 * Refresh a live entry's expiry to a full TTL from now (sliding expiration).
	 * Returns `false` if the key is missing or already expired (expired entries are evicted).
	 */
	touch(key: K): boolean {
		const entry = this.store.get(key);
		if (!entry) return false;
		if (this.deleteIfExpired(key, entry)) return false;
		entry.expiresAt = Date.now() + this.ttlMs;
		return true;
	}

	delete(key: K): boolean {
		return this.store.delete(key);
	}

	/** Iterate over non-expired keys. Triggers a sweep first to evict stale entries. */
	keys(): IterableIterator<K> {
		this.sweep();
		return this.store.keys();
	}

	/** Iterate over non-expired `[key, value]` pairs. */
	*entries(): IterableIterator<[K, V]> {
		const now = Date.now();
		for (const [key, entry] of this.store) {
			if (now <= entry.expiresAt) {
				yield [key, entry.value];
			}
		}
	}

	/** Makes `TtlMap` directly iterable: `for (const [k, v] of map)` works as expected. */
	[Symbol.iterator](): IterableIterator<[K, V]> {
		return this.entries();
	}

	/** Number of non-expired entries (triggers a sweep). */
	get size(): number {
		this.sweep();
		return this.store.size;
	}

	clear(): void {
		this.store.clear();
	}

	/** Evict all entries whose TTL has elapsed. */
	sweep(): void {
		const now = Date.now();
		for (const [key, entry] of this.store) {
			this.deleteIfExpired(key, entry, now);
		}
	}

	/** Stop the background sweep timer. */
	dispose(): void {
		if (this.sweepTimer) {
			clearInterval(this.sweepTimer);
			this.sweepTimer = null;
		}
	}
}
