import { render, act, cleanup } from '@testing-library/react';
import { StrictMode } from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';

const mockApi = vi.hoisted(() => ({
	initialize: vi.fn().mockResolvedValue(undefined),
	destroy: vi.fn(),
	sync: vi.fn(),
	isReady: vi.fn().mockReturnValue(true),
	on: vi.fn(),
	off: vi.fn(),
	configure: vi.fn(),
}));

const RevealConstructor = vi.hoisted(() =>
	vi.fn(function () {
		return mockApi;
	})
);

vi.mock('reveal.js', () => ({ default: RevealConstructor }));

import { Deck, Slide, Markdown, useReveal } from '../index';

beforeEach(() => {
	vi.clearAllMocks();
	mockApi.initialize.mockResolvedValue(undefined);
	mockApi.isReady.mockReturnValue(true);
});

describe('Deck', () => {
	it('renders .reveal > .slides structure', async () => {
		let container: HTMLElement;
		await act(async () => {
			({ container } = render(
				<Deck>
					<Slide>Hello</Slide>
				</Deck>
			));
		});

		const reveal = container!.querySelector('.reveal');
		expect(reveal).toBeInTheDocument();

		const slides = reveal?.querySelector('.slides');
		expect(slides).toBeInTheDocument();

		const section = slides?.querySelector('section');
		expect(section).toHaveTextContent('Hello');
	});

	it('calls new Reveal() and initialize() on mount', async () => {
		await act(async () => {
			render(
				<Deck config={{ transition: 'slide', hash: true }}>
					<Slide>Test</Slide>
				</Deck>
			);
		});

		expect(RevealConstructor).toHaveBeenCalledWith(
			expect.any(HTMLElement),
			expect.objectContaining({
				transition: 'slide',
				hash: true,
				plugins: [],
			})
		);
		expect(mockApi.initialize).toHaveBeenCalledTimes(1);
	});

	it('calls destroy() on unmount', async () => {
		await act(async () => {
			render(
				<Deck>
					<Slide>Test</Slide>
				</Deck>
			);
		});

		await act(async () => {
			cleanup();
		});

		expect(mockApi.destroy).toHaveBeenCalledTimes(1);
	});

	it('calls sync() after render when ready', async () => {
		await act(async () => {
			render(
				<Deck>
					<Slide>Test</Slide>
				</Deck>
			);
		});

		expect(mockApi.sync).toHaveBeenCalled();
	});

	it('does not call sync() when slide content changes without structural changes', async () => {
		let rerender: ReturnType<typeof render>['rerender'];
		await act(async () => {
			({ rerender } = render(
				<Deck>
					<Slide>
						<p>Tick 1</p>
					</Slide>
				</Deck>
			));
		});

		mockApi.sync.mockClear();

		await act(async () => {
			rerender(
				<Deck>
					<Slide>
						<p>Tick 2</p>
					</Slide>
				</Deck>
			);
		});

		expect(mockApi.sync).not.toHaveBeenCalled();
	});

	it('calls sync() when Markdown content changes', async () => {
		let rerender: ReturnType<typeof render>['rerender'];
		await act(async () => {
			({ rerender } = render(
				<Deck>
					<Markdown markdown="## Tick 1" />
				</Deck>
			));
		});

		mockApi.sync.mockClear();

		await act(async () => {
			rerender(
				<Deck>
					<Markdown markdown="## Tick 2" />
				</Deck>
			);
		});

		expect(mockApi.sync).toHaveBeenCalledTimes(1);
	});

	it('calls sync() when Markdown slide attributes change', async () => {
		let rerender: ReturnType<typeof render>['rerender'];
		await act(async () => {
			({ rerender } = render(
				<Deck>
					<Markdown markdown="## Tick" backgroundColor="#111111" />
				</Deck>
			));
		});

		mockApi.sync.mockClear();

		await act(async () => {
			rerender(
				<Deck>
					<Markdown markdown="## Tick" backgroundColor="#222222" />
				</Deck>
			);
		});

		expect(mockApi.sync).toHaveBeenCalledTimes(1);
	});

	it('does not call sync() twice when configure() already syncs', async () => {
		mockApi.configure.mockImplementation(() => {
			mockApi.sync();
		});

		let rerender: ReturnType<typeof render>['rerender'];
		await act(async () => {
			({ rerender } = render(
				<Deck config={{ transition: 'slide' }}>
					<Slide>Test</Slide>
				</Deck>
			));
		});

		mockApi.configure.mockClear();
		mockApi.sync.mockClear();

		await act(async () => {
			rerender(
				<Deck config={{ transition: 'convex' }}>
					<Slide>Test</Slide>
				</Deck>
			);
		});

		expect(mockApi.configure).toHaveBeenCalledTimes(1);
		expect(mockApi.sync).toHaveBeenCalledTimes(1);
	});

	it('calls sync() when keyed slides are reordered', async () => {
		let rerender: ReturnType<typeof render>['rerender'];
		await act(async () => {
			({ rerender } = render(
				<Deck>
					{['first', 'second'].map((label) => (
						<Slide key={label}>{label}</Slide>
					))}
				</Deck>
			));
		});

		mockApi.sync.mockClear();

		await act(async () => {
			rerender(
				<Deck>
					{['second', 'first'].map((label) => (
						<Slide key={label}>{label}</Slide>
					))}
				</Deck>
			);
		});

		expect(mockApi.sync).toHaveBeenCalledTimes(1);
	});

	it('calls sync() after children change even when isReady() was false during the config-change render', async () => {
		let rerender: ReturnType<typeof render>['rerender'];
		await act(async () => {
			({ rerender } = render(
				<Deck config={{ transition: 'slide' }}>
					<Slide>Slide 1</Slide>
				</Deck>
			));
		});

		// Make isReady() return true for the configure effect but false for the sync effect,
		// simulating Reveal briefly being non-ready during configuration. Without the fix the
		// skip flag would never be reset and the subsequent sync() call would be wrongly skipped.
		mockApi.isReady.mockReturnValueOnce(true).mockReturnValueOnce(false);

		await act(async () => {
			rerender(
				<Deck config={{ transition: 'convex' }}>
					<Slide>Slide 1</Slide>
				</Deck>
			);
		});

		mockApi.isReady.mockReturnValue(true);
		mockApi.sync.mockClear();

		await act(async () => {
			rerender(
				<Deck config={{ transition: 'convex' }}>
					<Slide>Slide 1</Slide>
					<Slide>Slide 2</Slide>
				</Deck>
			);
		});

		expect(mockApi.sync).toHaveBeenCalledTimes(1);
	});

	it('does not reconfigure when config object is recreated with same values', async () => {
		let rerender: ReturnType<typeof render>['rerender'];
		await act(async () => {
			({ rerender } = render(
				<Deck config={{ transition: 'slide', hash: true }}>
					<Slide>Test</Slide>
				</Deck>
			));
		});

		mockApi.configure.mockClear();

		await act(async () => {
			rerender(
				<Deck config={{ transition: 'slide', hash: true }}>
					<Slide>Test</Slide>
				</Deck>
			);
		});

		expect(mockApi.configure).not.toHaveBeenCalled();
	});

	it('fires onReady callback after initialization', async () => {
		const onReady = vi.fn();

		await act(async () => {
			render(
				<Deck onReady={onReady}>
					<Slide>Test</Slide>
				</Deck>
			);
		});

		expect(onReady).toHaveBeenCalledWith(mockApi);
	});

	it('wires onSlideChange to the slidechanged event', async () => {
		const onSlideChange = vi.fn();

		await act(async () => {
			render(
				<Deck onSlideChange={onSlideChange}>
					<Slide>Test</Slide>
				</Deck>
			);
		});

		expect(mockApi.on).toHaveBeenCalledWith('slidechanged', onSlideChange);
	});

	it('wires onSlideSync to the slidesync event', async () => {
		const onSlideSync = vi.fn();

		await act(async () => {
			render(
				<Deck onSlideSync={onSlideSync}>
					<Slide>Test</Slide>
				</Deck>
			);
		});

		expect(mockApi.on).toHaveBeenCalledWith('slidesync', onSlideSync);
	});

	it('cleans up event listeners on unmount', async () => {
		const onSlideChange = vi.fn();

		await act(async () => {
			render(
				<Deck onSlideChange={onSlideChange}>
					<Slide>Test</Slide>
				</Deck>
			);
		});

		await act(async () => {
			cleanup();
		});

		expect(mockApi.off).toHaveBeenCalledWith('slidechanged', onSlideChange);
	});

	it('passes plugins to the Reveal constructor', async () => {
		const fakePlugin = () => ({ id: 'fake', init: () => {} });

		await act(async () => {
			render(
				<Deck plugins={[fakePlugin]}>
					<Slide>Test</Slide>
				</Deck>
			);
		});

		expect(RevealConstructor).toHaveBeenCalledWith(
			expect.any(HTMLElement),
			expect.objectContaining({ plugins: [fakePlugin] })
		);
	});

	it('registers plugins only on initialization', async () => {
		const pluginA = () => ({ id: 'a', init: () => {} });
		const pluginB = () => ({ id: 'b', init: () => {} });

		let rerender: ReturnType<typeof render>['rerender'];
		await act(async () => {
			({ rerender } = render(
				<Deck plugins={[pluginA]}>
					<Slide>Test</Slide>
				</Deck>
			));
		});

		mockApi.configure.mockClear();

		await act(async () => {
			rerender(
				<Deck plugins={[pluginB]}>
					<Slide>Test</Slide>
				</Deck>
			);
		});

		expect(RevealConstructor).toHaveBeenCalledTimes(1);
		expect(RevealConstructor).toHaveBeenCalledWith(
			expect.any(HTMLElement),
			expect.objectContaining({ plugins: [pluginA] })
		);
		expect(mockApi.configure).not.toHaveBeenCalled();
	});

	it('applies className and style to the .reveal div', async () => {
		let container: HTMLElement;
		await act(async () => {
			({ container } = render(
				<Deck className="custom" style={{ height: '400px' }}>
					<Slide>Test</Slide>
				</Deck>
			));
		});

		const reveal = container!.querySelector('.reveal');
		expect(reveal).toHaveClass('reveal', 'custom');
		expect(reveal).toHaveStyle({ height: '400px' });
	});

	it('does not initialize twice in StrictMode', async () => {
		await act(async () => {
			render(
				<StrictMode>
					<Deck>
						<Slide>Test</Slide>
					</Deck>
				</StrictMode>
			);
		});

		expect(RevealConstructor).toHaveBeenCalledTimes(1);
		expect(mockApi.initialize).toHaveBeenCalledTimes(1);
	});

	it('updates deckRef when the ref prop changes', async () => {
		const refA = vi.fn();
		const refB = vi.fn();

		let rerender: ReturnType<typeof render>['rerender'];
		let unmount: ReturnType<typeof render>['unmount'];
		await act(async () => {
			({ rerender, unmount } = render(
				<Deck deckRef={refA}>
					<Slide>Test</Slide>
				</Deck>
			));
		});

		expect(refA).toHaveBeenCalledWith(mockApi);

		await act(async () => {
			rerender(
				<Deck deckRef={refB}>
					<Slide>Test</Slide>
				</Deck>
			);
		});

		expect(refA).toHaveBeenCalledWith(null);
		expect(refB).toHaveBeenCalledWith(mockApi);

		await act(async () => {
			unmount();
		});

		expect(refB).toHaveBeenCalledWith(null);
	});
});

describe('useReveal', () => {
	it('returns the RevealApi instance inside a Deck', async () => {
		let hookResult: any = undefined;

		function Inspector() {
			hookResult = useReveal();
			return null;
		}

		await act(async () => {
			render(
				<Deck>
					<Slide>
						<Inspector />
					</Slide>
				</Deck>
			);
		});

		expect(hookResult).toBe(mockApi);
	});

	it('returns null outside of a Deck', () => {
		let hookResult: any = 'not-null';

		function Inspector() {
			hookResult = useReveal();
			return null;
		}

		render(<Inspector />);
		expect(hookResult).toBeNull();
	});
});
