import type { IVersionNotificationSettings } from '@n8n/api-types';
import { useToast } from '@n8n/composables/useToast';
import { VERSIONS_MODAL_KEY, WHATS_NEW_MODAL_KEY } from '@n8n/frontend-constants/versions';
import type { IUser } from '@n8n/rest-api-client/api/users';
import * as versionsApi from '@n8n/rest-api-client/api/versions';
import type { Version, WhatsNewArticle, WhatsNewSection } from '@n8n/rest-api-client/api/versions';
import { createPinia, setActivePinia } from 'pinia';
import { mock } from 'vitest-mock-extended';

import type { ModalOpeners } from './modalOpeners';
import { useSettingsStore } from './settings.store';
import { useRootStore } from './useRootStore';
import { useUsersStore } from './users.store';
import { useVersionsStore } from './versions.store';

vi.mock('@n8n/composables/useToast', () => {
	const showToast = vi.fn();
	const showMessage = vi.fn();
	return {
		useToast: () => {
			return {
				showToast,
				showMessage,
			};
		},
	};
});

vi.mock('./users.store');

/**
 * Stub `useUsersStore` with just the `currentUser` this store reads —
 * `shouldShowWhatsNewCallout` only looks at `createdAt`.
 */
const mockCurrentUser = (currentUser: Partial<IUser> | null) => {
	vi.mocked(useUsersStore).mockReturnValue(
		mock<ReturnType<typeof useUsersStore>>({
			currentUser: currentUser && mock<IUser>(currentUser),
		}),
	);
};

const settings: IVersionNotificationSettings = {
	enabled: true,
	endpoint: 'https://test.api.n8n.io/api/versions/',
	whatsNewEnabled: true,
	whatsNewEndpoint: 'https://test.api.n8n.io/api/whats-new',
	infoUrl: 'https://test.docs.n8n.io/hosting/installation/updating/',
};

const instanceId = 'test-instance-id';
const currentVersionName = '1.100.0';
const currentVersion: Version = {
	name: currentVersionName,
	nodes: [],
	createdAt: '2025-06-24T00:00:00Z',
	description: 'Latest version description',
	documentationUrl: 'https://docs.n8n.io',
	hasBreakingChange: false,
	hasSecurityFix: false,
	hasSecurityIssue: false,
	securityIssueFixVersion: '',
};

const whatsNewArticle: WhatsNewArticle = {
	id: 1,
	title: 'Test article',
	content: 'Some markdown content here',
	createdAt: '2025-06-19T12:37:54.885Z',
	updatedAt: '2025-06-19T12:41:44.919Z',
	publishedAt: '2025-06-19T12:41:44.914Z',
};

const whatsNew: WhatsNewSection = {
	title: "What's New title",
	calloutText: 'Callout text.',
	footer: "What's new footer",
	items: [whatsNewArticle],
	createdAt: '2025-06-19T12:37:54.885Z',
	updatedAt: '2025-06-19T12:41:44.919Z',
};

const toast = useToast();

// Recreated per test (see beforeEach) so call history never leaks between tests —
// vi.restoreAllMocks() resets spies from vi.spyOn but not standalone vi.fn()s.
let openModal: ModalOpeners['openModal'];
let openModalWithData: ModalOpeners['openModalWithData'];

describe('versions.store', () => {
	beforeEach(() => {
		vi.restoreAllMocks();
		// The toast mocks live in the vi.mock factory and persist across tests;
		// clear them so `.mock.calls` indexing reflects only the current test.
		vi.mocked(toast.showToast).mockClear();
		vi.mocked(toast.showMessage).mockClear();
		localStorage.clear();
		setActivePinia(createPinia());
		openModal = vi.fn();
		openModalWithData = vi.fn();
	});

	describe('fetchVersions()', () => {
		it('should fetch versions and set current version', async () => {
			vi.spyOn(versionsApi, 'getNextVersions').mockResolvedValue([currentVersion]);

			const rootStore = useRootStore();
			rootStore.setVersionCli(currentVersionName);
			rootStore.setInstanceId(instanceId);

			const versionsStore = useVersionsStore();
			versionsStore.initialize(settings);

			await versionsStore.fetchVersions();

			expect(versionsApi.getNextVersions).toHaveBeenCalledWith(
				settings.endpoint,
				currentVersionName,
				instanceId,
			);

			expect(versionsStore.nextVersions).toEqual([]);
			expect(versionsStore.currentVersion).toEqual(currentVersion);
		});

		it('should not fetch versions if not enabled', async () => {
			vi.spyOn(versionsApi, 'getNextVersions');

			const versionsStore = useVersionsStore();
			versionsStore.initialize({
				...settings,
				enabled: false,
			});

			await versionsStore.fetchVersions();

			expect(versionsApi.getNextVersions).not.toHaveBeenCalled();
			expect(versionsStore.nextVersions).toEqual([]);
			expect(versionsStore.currentVersion).toEqual(undefined);
		});
	});

	describe('fetchWhatsNew()', () => {
		it("should fetch What's new articles", async () => {
			vi.spyOn(versionsApi, 'getWhatsNewSection').mockResolvedValue(whatsNew);

			const rootStore = useRootStore();
			rootStore.setVersionCli(currentVersionName);
			rootStore.setInstanceId(instanceId);

			const versionsStore = useVersionsStore();
			versionsStore.initialize(settings);

			versionsStore.registerModalOpeners({ openModal, openModalWithData });
			await versionsStore.fetchWhatsNew();

			expect(versionsApi.getWhatsNewSection).toHaveBeenCalledWith(
				settings.whatsNewEndpoint,
				currentVersionName,
				instanceId,
			);

			expect(versionsStore.whatsNewArticles).toEqual([whatsNewArticle]);
		});

		it('should dismiss the callout as soon as it is shown', async () => {
			vi.spyOn(versionsApi, 'getWhatsNewSection').mockResolvedValue(whatsNew);
			mockCurrentUser(null);

			const rootStore = useRootStore();
			rootStore.setVersionCli(currentVersionName);
			rootStore.setInstanceId(instanceId);

			const versionsStore = useVersionsStore();
			versionsStore.initialize(settings);

			versionsStore.registerModalOpeners({ openModal, openModalWithData });
			await versionsStore.fetchWhatsNew();

			// The callout has been shown ...
			expect(toast.showMessage).toHaveBeenCalled();
			// ... and is immediately marked as dismissed so it does not reappear on the
			// next load, even though the user never explicitly closed it.
			expect(versionsStore.shouldShowWhatsNewCallout()).toBe(false);
		});

		it("should open the what's new modal via the registered opener when the callout is clicked", async () => {
			vi.spyOn(versionsApi, 'getWhatsNewSection').mockResolvedValue(whatsNew);
			mockCurrentUser(null);

			const rootStore = useRootStore();
			rootStore.setVersionCli(currentVersionName);
			rootStore.setInstanceId(instanceId);

			const versionsStore = useVersionsStore();
			versionsStore.initialize(settings);

			versionsStore.registerModalOpeners({ openModal, openModalWithData });
			await versionsStore.fetchWhatsNew();

			const onClick = vi.mocked(toast.showMessage).mock.calls[0][0].onClick;
			onClick?.();

			expect(openModalWithData).toHaveBeenCalledWith({
				name: WHATS_NEW_MODAL_KEY,
				data: { articleId: whatsNewArticle.id },
			});
		});

		it("should not fetch What's new articles if version notifications are disabled", async () => {
			vi.spyOn(versionsApi, 'getWhatsNewSection');

			const versionsStore = useVersionsStore();
			versionsStore.initialize({
				...settings,
				enabled: false,
			});

			versionsStore.registerModalOpeners({ openModal, openModalWithData });
			await versionsStore.fetchWhatsNew();

			expect(versionsApi.getWhatsNewSection).not.toHaveBeenCalled();
			expect(versionsStore.whatsNewArticles).toEqual([]);
		});

		it("should not fetch What's new articles if not enabled", async () => {
			vi.spyOn(versionsApi, 'getWhatsNewSection');

			const versionsStore = useVersionsStore();
			versionsStore.initialize({
				...settings,
				enabled: true,
				whatsNewEnabled: false,
			});

			versionsStore.registerModalOpeners({ openModal, openModalWithData });
			await versionsStore.fetchWhatsNew();

			expect(versionsApi.getWhatsNewSection).not.toHaveBeenCalled();
			expect(versionsStore.whatsNewArticles).toEqual([]);
		});
	});

	describe('checkForNewVersions()', () => {
		it('should check for new versions', async () => {
			vi.spyOn(versionsApi, 'getWhatsNewSection').mockResolvedValue(whatsNew);
			vi.spyOn(versionsApi, 'getNextVersions').mockResolvedValue([currentVersion]);

			const rootStore = useRootStore();
			rootStore.setVersionCli(currentVersionName);
			rootStore.setInstanceId(instanceId);

			const versionsStore = useVersionsStore();
			versionsStore.initialize(settings);

			versionsStore.registerModalOpeners({ openModal, openModalWithData });
			await versionsStore.checkForNewVersions();

			expect(versionsApi.getWhatsNewSection).toHaveBeenCalledWith(
				settings.whatsNewEndpoint,
				currentVersionName,
				instanceId,
			);

			expect(versionsStore.whatsNewArticles).toEqual([whatsNewArticle]);

			expect(versionsApi.getNextVersions).toHaveBeenCalledWith(
				settings.endpoint,
				currentVersionName,
				instanceId,
			);

			expect(versionsStore.nextVersions).toEqual([]);
			expect(versionsStore.currentVersion).toEqual(currentVersion);
		});

		it("should still initialize versions if what's new articles fail", async () => {
			vi.spyOn(versionsApi, 'getWhatsNewSection').mockRejectedValueOnce(new Error('oopsie'));
			vi.spyOn(versionsApi, 'getNextVersions').mockResolvedValue([currentVersion]);

			const rootStore = useRootStore();
			rootStore.setVersionCli(currentVersionName);
			rootStore.setInstanceId(instanceId);

			const versionsStore = useVersionsStore();
			versionsStore.initialize({
				enabled: true,
				endpoint: 'https://api.n8n.io/api/versions/',
				whatsNewEnabled: true,
				whatsNewEndpoint: 'https://api.n8n.io/api/whats-new',
				infoUrl: 'https://docs.n8n.io/hosting/installation/updating/',
			});

			versionsStore.registerModalOpeners({ openModal, openModalWithData });
			await versionsStore.checkForNewVersions();

			expect(versionsStore.whatsNewArticles).toEqual([]);
			expect(versionsStore.nextVersions).toEqual([]);
			expect(versionsStore.currentVersion).toEqual(currentVersion);
		});

		it("should still initialize what's new articles if versions fail", async () => {
			vi.spyOn(versionsApi, 'getWhatsNewSection').mockResolvedValue(whatsNew);
			vi.spyOn(versionsApi, 'getNextVersions').mockRejectedValueOnce(new Error('oopsie'));

			const rootStore = useRootStore();
			rootStore.setVersionCli(currentVersionName);
			rootStore.setInstanceId(instanceId);

			const versionsStore = useVersionsStore();
			versionsStore.initialize({
				enabled: true,
				endpoint: 'https://api.n8n.io/api/versions/',
				whatsNewEnabled: true,
				whatsNewEndpoint: 'https://api.n8n.io/api/whats-new',
				infoUrl: 'https://docs.n8n.io/hosting/installation/updating/',
			});

			versionsStore.registerModalOpeners({ openModal, openModalWithData });
			await versionsStore.checkForNewVersions();

			expect(versionsStore.whatsNewArticles).toEqual([whatsNewArticle]);
			expect(versionsStore.nextVersions).toEqual([]);
			expect(versionsStore.currentVersion).toEqual(undefined);
		});

		it('should show toast if important version updates are available', async () => {
			vi.spyOn(versionsApi, 'getWhatsNewSection').mockResolvedValue({ ...whatsNew, items: [] });
			vi.spyOn(versionsApi, 'getNextVersions').mockResolvedValue([
				{
					...currentVersion,
					hasSecurityIssue: true,
					securityIssueFixVersion: '1.100.1',
				},
				{
					...currentVersion,
					name: '1.100.1',
					hasSecurityFix: true,
				},
			]);

			const rootStore = useRootStore();
			rootStore.setVersionCli(currentVersionName);
			rootStore.setInstanceId(instanceId);

			const versionsStore = useVersionsStore();
			versionsStore.initialize(settings);

			versionsStore.registerModalOpeners({ openModal, openModalWithData });
			await versionsStore.checkForNewVersions();

			expect(versionsStore.nextVersions).toHaveLength(1);
			expect(versionsStore.nextVersions[0].name).toBe('1.100.1');
			expect(toast.showToast).toHaveBeenCalledWith(
				expect.objectContaining({
					title: 'Critical update available',
					message: expect.stringContaining('Please update to version 1.100.1 or higher.'),
					type: 'warning',
				}),
			);

			// Clicking the toast opens the versions modal through the registered opener.
			const onClick = vi.mocked(toast.showToast).mock.calls[0][0].onClick;
			onClick?.();
			expect(openModal).toHaveBeenCalledWith(VERSIONS_MODAL_KEY);
		});

		it('does not throw when opening a modal before openers are registered', async () => {
			vi.spyOn(versionsApi, 'getWhatsNewSection').mockResolvedValue({ ...whatsNew, items: [] });
			vi.spyOn(versionsApi, 'getNextVersions').mockResolvedValue([
				{ ...currentVersion, hasSecurityIssue: true, securityIssueFixVersion: '1.100.1' },
				{ ...currentVersion, name: '1.100.1', hasSecurityFix: true },
			]);

			const rootStore = useRootStore();
			rootStore.setVersionCli(currentVersionName);
			rootStore.setInstanceId(instanceId);

			const versionsStore = useVersionsStore();
			versionsStore.initialize(settings);

			// No registerModalOpeners() — the default no-op opener must not break the flow.
			await versionsStore.checkForNewVersions();

			const onClick = vi.mocked(toast.showToast).mock.calls[0][0].onClick;
			expect(() => onClick?.()).not.toThrow();
			expect(openModal).not.toHaveBeenCalled();
		});
	});

	describe('setWhatsNewArticleRead()', () => {
		it('should add article ID to read articles', () => {
			const versionsStore = useVersionsStore();

			expect(versionsStore.isWhatsNewArticleRead(1)).toBe(false);
			expect(versionsStore.isWhatsNewArticleRead(2)).toBe(false);

			versionsStore.setWhatsNewArticleRead(1);
			versionsStore.setWhatsNewArticleRead(2);

			expect(versionsStore.isWhatsNewArticleRead(1)).toBe(true);
			expect(versionsStore.isWhatsNewArticleRead(2)).toBe(true);
			expect(versionsStore.isWhatsNewArticleRead(404)).toBe(false);
		});
	});

	describe('hasVersionUpdates', () => {
		it('should return true on stable if there are next versions', () => {
			const settingsStore = useSettingsStore();
			settingsStore.settings.releaseChannel = 'stable';

			const versionsStore = useVersionsStore();
			versionsStore.nextVersions = [
				{
					...currentVersion,
					name: '1.100.1',
				},
			];
			versionsStore.currentVersion = currentVersion;

			expect(versionsStore.hasVersionUpdates).toBe(true);
		});

		it('should return false on stable if there are no next versions', () => {
			const versionsStore = useVersionsStore();
			versionsStore.nextVersions = [];
			versionsStore.currentVersion = currentVersion;

			expect(versionsStore.hasVersionUpdates).toBe(false);
		});

		it('should return false on beta if there are next versions', () => {
			const versionsStore = useVersionsStore();
			versionsStore.nextVersions = [
				{
					...currentVersion,
					name: '1.100.1',
				},
			];
			versionsStore.currentVersion = currentVersion;

			expect(versionsStore.hasVersionUpdates).toBe(false);
		});
	});

	describe('latestVersion', () => {
		it('should return the latest version from next versions', () => {
			const versionsStore = useVersionsStore();
			versionsStore.nextVersions = [
				{
					...currentVersion,
					name: '1.100.2',
				},
				{
					...currentVersion,
					name: '1.100.1',
				},
			];
			versionsStore.currentVersion = currentVersion;

			expect(versionsStore.latestVersion.name).toBe('1.100.2');
		});

		it('should return current version if no next versions', () => {
			const versionsStore = useVersionsStore();
			versionsStore.nextVersions = [];
			versionsStore.currentVersion = currentVersion;

			expect(versionsStore.latestVersion.name).toBe(currentVersionName);
		});
	});

	describe('hasSignificantUpdates', () => {
		beforeEach(() => {
			const settingsStore = useSettingsStore();
			settingsStore.settings.releaseChannel = 'stable';
		});

		it('should return true if current version is behind by at least two minor versions', () => {
			const versionsStore = useVersionsStore();

			versionsStore.currentVersion = currentVersion;
			versionsStore.nextVersions = [
				{
					...currentVersion,
					name: '1.102.0',
				},
				{
					...currentVersion,
					name: '1.101.0',
				},
			];

			expect(versionsStore.hasSignificantUpdates).toBe(true);
		});

		it('should return true if current version is behind by at least two minor versions, more exotic versions', () => {
			const versionsStore = useVersionsStore();

			versionsStore.currentVersion = {
				...currentVersion,
				name: '1.100.1+rc.1',
			};

			versionsStore.nextVersions = [
				{
					...currentVersion,
					name: '1.102.0-alpha+20180301',
				},
				{
					...currentVersion,
					name: '1.101.0',
				},
			];

			expect(versionsStore.hasSignificantUpdates).toBe(true);
		});

		it('should return true if current version has security issue', () => {
			const versionsStore = useVersionsStore();

			versionsStore.currentVersion = {
				...currentVersion,
				hasSecurityIssue: true,
			};

			versionsStore.nextVersions = [
				{
					...currentVersion,
					name: '1.101.0',
				},
			];

			expect(versionsStore.hasSignificantUpdates).toBe(true);
		});

		it('should return false if current version is not behind by at least two minor versions', () => {
			const versionsStore = useVersionsStore();
			versionsStore.currentVersion = {
				...currentVersion,
				name: '1.101.0',
			};
			versionsStore.nextVersions = [
				{
					...currentVersion,
					name: '1.102.0',
				},
				{
					...currentVersion,
					name: '1.101.1',
				},
			];

			expect(versionsStore.hasSignificantUpdates).toBe(false);
		});

		it('should return false if current version is only behind by patch versions', () => {
			const versionsStore = useVersionsStore();
			versionsStore.currentVersion = currentVersion;
			versionsStore.nextVersions = [
				{
					...currentVersion,
					name: '1.100.9',
				},
			];

			expect(versionsStore.hasSignificantUpdates).toBe(false);
		});

		it('should return true if current version is behind by a major', () => {
			const versionsStore = useVersionsStore();
			versionsStore.currentVersion = {
				...currentVersion,
				name: '1.100.0',
			};
			versionsStore.nextVersions = [
				{
					...currentVersion,
					name: '2.0.0',
				},
			];

			expect(versionsStore.hasSignificantUpdates).toBe(true);
		});

		it('should return false if current version is not semver', () => {
			const versionsStore = useVersionsStore();

			versionsStore.currentVersion = {
				...currentVersion,
				name: 'alpha-1',
			};

			versionsStore.nextVersions = [
				{
					...currentVersion,
					name: '1.100.2',
				},
			];

			expect(versionsStore.hasSignificantUpdates).toBe(false);
		});

		it('should return false if latest version is not semver', () => {
			const versionsStore = useVersionsStore();

			versionsStore.currentVersion = currentVersion;
			versionsStore.nextVersions = [
				{
					...currentVersion,
					name: 'alpha-2',
				},
			];

			expect(versionsStore.hasSignificantUpdates).toBe(false);
		});
	});
});

describe('shouldShowWhatsNewCallout', () => {
	let versionsStore: ReturnType<typeof useVersionsStore>;

	const makeArticle = (overrides: Partial<WhatsNewArticle> = {}): WhatsNewArticle => ({
		id: 1,
		title: 'Test',
		content: 'Content',
		createdAt: new Date().toISOString(),
		updatedAt: new Date().toISOString(),
		publishedAt: new Date().toISOString(),
		...overrides,
	});

	beforeEach(() => {
		localStorage.clear();
		setActivePinia(createPinia());
	});

	it('returns false if there are no articles', () => {
		mockCurrentUser(null);
		versionsStore = useVersionsStore();
		Object.defineProperty(versionsStore, 'lastDismissedWhatsNewCallout', { get: () => [] });
		versionsStore.whatsNew.items = [];
		expect(versionsStore.shouldShowWhatsNewCallout()).toBe(false);
	});

	it('returns true if user has no createdAt and not all articles are dismissed', () => {
		mockCurrentUser(null);
		versionsStore = useVersionsStore();
		Object.defineProperty(versionsStore, 'lastDismissedWhatsNewCallout', { get: () => [] });
		versionsStore.whatsNew.items = [makeArticle()];
		expect(versionsStore.shouldShowWhatsNewCallout()).toBe(true);
	});

	it('returns false if all articles are dismissed', () => {
		mockCurrentUser(null);
		versionsStore = useVersionsStore();
		versionsStore.whatsNew.items = [makeArticle()];
		versionsStore.dismissWhatsNewCallout();
		expect(versionsStore.shouldShowWhatsNewCallout()).toBe(false);
	});

	it('returns true if user createdAt is before article updatedAt', () => {
		const now = Date.now();
		mockCurrentUser({ createdAt: new Date(now - 10000).toISOString() });
		versionsStore = useVersionsStore();
		Object.defineProperty(versionsStore, 'lastDismissedWhatsNewCallout', { get: () => [] });
		versionsStore.whatsNew.items = [makeArticle({ updatedAt: new Date(now).toISOString() })];
		expect(versionsStore.shouldShowWhatsNewCallout()).toBe(true);
	});

	it('returns false if user createdAt is after article updatedAt', () => {
		const now = Date.now();
		mockCurrentUser({ createdAt: new Date(now).toISOString() });
		versionsStore = useVersionsStore();
		Object.defineProperty(versionsStore, 'lastDismissedWhatsNewCallout', { get: () => [] });
		versionsStore.whatsNew.items = [
			makeArticle({ updatedAt: new Date(now - 10000).toISOString() }),
		];
		expect(versionsStore.shouldShowWhatsNewCallout()).toBe(false);
	});

	it('handles missing updatedAt on article', () => {
		mockCurrentUser({ createdAt: new Date().toISOString() });
		versionsStore = useVersionsStore();
		Object.defineProperty(versionsStore, 'lastDismissedWhatsNewCallout', { get: () => [] });
		versionsStore.whatsNew.items = [makeArticle({ updatedAt: undefined })];
		expect(versionsStore.shouldShowWhatsNewCallout()).toBe(false);
	});
});
