import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { NotificationItem } from '$lib/notification/notifications';

let lastEventSource: MockEventSource | null = null;
let eventSourceCount = 0;

class MockEventSource {
	static CONNECTING = 0;
	static OPEN = 1;
	static CLOSED = 2;

	readyState = MockEventSource.CONNECTING;
	onopen: (() => void) | null = null;
	onerror: (() => void | Promise<void>) | null = null;
	close = vi.fn();
	private listeners: Record<string, ((e: MessageEvent) => void)[]> = {};

	constructor() {
		eventSourceCount += 1;
		// eslint-disable-next-line @typescript-eslint/no-this-alias
		lastEventSource = this;
	}

	addEventListener(type: string, fn: (e: MessageEvent) => void) {
		if (!this.listeners[type]) this.listeners[type] = [];
		this.listeners[type].push(fn);
	}

	simulate(type: string, data: string) {
		const event = new MessageEvent(type, { data });
		for (const fn of this.listeners[type] ?? []) {
			fn(event);
		}
	}
}

vi.stubGlobal('EventSource', MockEventSource);

const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);

const { createNotificationStore } = await import('./notifications.svelte');

let store: ReturnType<typeof createNotificationStore>;
let navigateSpy: ReturnType<typeof vi.fn<(url: string) => void>>;

beforeEach(() => {
	mockFetch.mockReset();
	mockFetch.mockResolvedValue(new Response(JSON.stringify({ count: 0 }), { status: 200 }));
	lastEventSource = null;
	eventSourceCount = 0;
	navigateSpy = vi.fn<(url: string) => void>();
	// A fresh instance per test replaces the old __resetForTest() singleton reset.
	store = createNotificationStore();
	store.setNavigate(navigateSpy);
});

function makeNotification(overrides: Partial<NotificationItem> = {}): NotificationItem {
	return {
		id: 'n1',
		type: 'REPLY',
		actorName: 'Hans',
		documentId: 'doc-1',
		documentTitle: null,
		referenceId: 'ref-1',
		annotationId: null,
		read: false,
		createdAt: new Date().toISOString(),
		...overrides
	};
}

describe('notification store', () => {
	it('opens a single EventSource across multiple init() calls', () => {
		store.init();
		store.init();
		store.init();

		expect(eventSourceCount).toBe(1);
	});

	it('closes the EventSource only after every init() is matched with destroy()', () => {
		store.init();
		store.init();
		const es = lastEventSource!;

		store.destroy();
		expect(es.close).not.toHaveBeenCalled();

		store.destroy();
		expect(es.close).toHaveBeenCalledTimes(1);
	});

	it('reopens a fresh EventSource after full teardown', () => {
		store.init();
		store.destroy();
		store.init();

		expect(eventSourceCount).toBe(2);
	});

	it('SSE notification event prepends notification and increments unreadCount', () => {
		store.init();

		const notification = makeNotification({ id: 'sse-1', read: false });
		lastEventSource!.simulate('notification', JSON.stringify(notification));

		expect(store.notifications[0].id).toBe('sse-1');
		expect(store.unreadCount).toBe(1);
	});

	it('optimisticMarkRead marks the notification read and decrements unreadCount without fetching', () => {
		store.init();
		const notification = makeNotification({ id: 'sse-1', read: false });
		lastEventSource!.simulate('notification', JSON.stringify(notification));
		mockFetch.mockReset(); // clear the fetchUnreadCount call from init

		store.optimisticMarkRead('sse-1');

		expect(store.notifications[0].read).toBe(true);
		expect(store.unreadCount).toBe(0);
		expect(mockFetch).not.toHaveBeenCalled();
	});

	it('optimisticMarkRead on an already-read notification does not decrement unreadCount below 0', () => {
		store.init();
		const notification = makeNotification({ id: 'sse-1', read: true });
		lastEventSource!.simulate('notification', JSON.stringify(notification));

		store.optimisticMarkRead('sse-1');

		expect(store.unreadCount).toBe(0);
	});

	it('optimisticMarkAllRead resets unreadCount and marks all notifications read without fetching', () => {
		store.init();
		lastEventSource!.simulate(
			'notification',
			JSON.stringify(makeNotification({ id: 'n1', read: false }))
		);
		lastEventSource!.simulate(
			'notification',
			JSON.stringify(makeNotification({ id: 'n2', read: false }))
		);
		mockFetch.mockReset();

		store.optimisticMarkAllRead();

		expect(store.unreadCount).toBe(0);
		expect(store.notifications.every((n) => n.read)).toBe(true);
		expect(mockFetch).not.toHaveBeenCalled();
	});
});

describe('notification store onerror handler', () => {
	it('redirects to /login when readyState is CLOSED and server returns 401', async () => {
		mockFetch.mockResolvedValue(new Response(null, { status: 401 }));
		store.init();
		const es = lastEventSource!;
		es.readyState = MockEventSource.CLOSED;

		await es.onerror?.();

		expect(navigateSpy).toHaveBeenCalledWith('/login');
	});

	it('does not redirect when readyState is CLOSED and session is still valid', async () => {
		store.init();
		const es = lastEventSource!;
		es.readyState = MockEventSource.CLOSED;

		await es.onerror?.();

		expect(navigateSpy).not.toHaveBeenCalled();
	});

	it('does not close or redirect before the error threshold when readyState is CONNECTING', async () => {
		store.init();
		const es = lastEventSource!;
		es.readyState = MockEventSource.CONNECTING;

		await es.onerror?.();
		await es.onerror?.();

		expect(es.close).not.toHaveBeenCalled();
		expect(navigateSpy).not.toHaveBeenCalled();
	});

	it('closes and redirects after 3 consecutive CONNECTING errors when session returns 401', async () => {
		mockFetch.mockResolvedValue(new Response(null, { status: 401 }));
		store.init();
		const es = lastEventSource!;
		es.readyState = MockEventSource.CONNECTING;

		await es.onerror?.();
		await es.onerror?.();
		await es.onerror?.();

		expect(es.close).toHaveBeenCalledTimes(1);
		expect(navigateSpy).toHaveBeenCalledWith('/login');
	});

	it('closes but does not redirect after threshold when session is still valid', async () => {
		store.init();
		const es = lastEventSource!;
		es.readyState = MockEventSource.CONNECTING;

		await es.onerror?.();
		await es.onerror?.();
		await es.onerror?.();

		expect(es.close).toHaveBeenCalledTimes(1);
		expect(navigateSpy).not.toHaveBeenCalled();
	});

	it('resets error count after a successful reconnect (onopen)', async () => {
		store.init();
		const es = lastEventSource!;
		es.readyState = MockEventSource.CONNECTING;

		// Two errors — not yet at threshold
		await es.onerror?.();
		await es.onerror?.();

		// Successful reconnect resets counter
		es.onopen?.();

		// Two more errors — should still be below threshold
		await es.onerror?.();
		await es.onerror?.();

		expect(es.close).not.toHaveBeenCalled();
		expect(navigateSpy).not.toHaveBeenCalled();
	});
});
