import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { Component, Suspense } from '@wordpress/element';
import {
	createRegistry,
	createReduxStore,
	useSuspenseSelect,
	RegistryProvider,
} from '@wordpress/data';

function createRegistryWithStore() {
	const initialState = {
		prefix: 'pre-',
		token: null,
		data: null,
		fails: true,
	};

	const reducer = ( state = initialState, action ) => {
		switch ( action.type ) {
			case 'RECEIVE_TOKEN':
				return { ...state, token: action.token };
			case 'RECEIVE_DATA':
				return { ...state, data: action.data };
			default:
				return state;
		}
	};

	const selectors = {
		getPrefix: ( state ) => state.prefix,
		getToken: ( state ) => state.token,
		getData: ( state, token ) => {
			if ( ! token ) {
				throw 'missing token in selector';
			}
			return state.data;
		},
		getThatFails: ( state ) => state.fails,
	};

	const sleep = ( ms ) => new Promise( ( r ) => setTimeout( () => r(), ms ) );

	const resolvers = {
		getToken:
			() =>
			async ( { dispatch } ) => {
				await sleep( 10 );
				dispatch( { type: 'RECEIVE_TOKEN', token: 'token' } );
			},
		getData:
			( token ) =>
			async ( { dispatch } ) => {
				await sleep( 10 );
				if ( ! token ) {
					throw 'missing token in resolver';
				}
				dispatch( { type: 'RECEIVE_DATA', data: 'therealdata' } );
			},
		getThatFails: () => async () => {
			await sleep( 10 );
			throw 'resolution failed';
		},
	};

	const store = createReduxStore( 'test', {
		reducer,
		selectors,
		resolvers,
	} );

	const registry = createRegistry();
	registry.register( store );

	return { registry, store };
}

describe( 'useSuspenseSelect', () => {
	it( 'renders after suspending a few times', async () => {
		const { registry, store } = createRegistryWithStore();
		let attempts = 0;
		let renders = 0;

		const UI = () => {
			attempts++;
			const { result } = useSuspenseSelect( ( select ) => {
				const prefix = select( store ).getPrefix();
				const token = select( store ).getToken();
				const data = select( store ).getData( token );
				return { result: prefix + data };
			}, [] );
			renders++;
			return <div aria-label="loaded">{ result }</div>;
		};

		const App = () => (
			<RegistryProvider value={ registry }>
				<Suspense fallback="loading">
					<UI />
				</Suspense>
			</RegistryProvider>
		);

		render( <App /> );
		await screen.findByLabelText( 'loaded' );

		// Verify there were 4 attempts to render:
		// - initial render with `getToken` not being resolved and suspending, rendering fallback;
		// - second render for sibling prerendering which collects all other suspending promises early;
		// - third render after `getToken` is resolved and `getData` suspends;
		// - final fourth render after all data got loaded.
		expect( attempts ).toBe( 4 );
		expect( renders ).toBe( 1 );
	} );

	it( 'shows error when resolution fails', async () => {
		const { registry, store } = createRegistryWithStore();

		const UI = () => {
			const { token } = useSuspenseSelect( ( select ) => {
				// Call a selector whose resolution fails. The `useSuspenseSelect`
				// is then supposed to throw the resolution error.
				return { token: select( store ).getThatFails() };
			}, [] );
			return <div aria-label="loaded">{ token }</div>;
		};

		class Error extends Component {
			state = { error: null };

			static getDerivedStateFromError( error ) {
				return { error };
			}

			render() {
				const { children } = this.props;
				if ( this.state.error ) {
					return <div aria-label="error">{ this.state.error }</div>;
				}
				return children;
			}
		}

		const App = () => (
			<RegistryProvider value={ registry }>
				<Error>
					<Suspense fallback="loading">
						<UI />
					</Suspense>
				</Error>
			</RegistryProvider>
		);

		// Caught render errors are reported here. This replaces React's default console.error.
		const onError = vi.fn();

		render( <App />, { onCaughtError: onError } );
		const label = await screen.findByLabelText( 'error' );
		expect( label ).toHaveTextContent( 'resolution failed' );
		expect( onError ).toHaveBeenCalledWith(
			'resolution failed',
			expect.objectContaining( {
				errorBoundary: expect.any( Error ),
			} )
		);
	} );

	it( 'independent resolutions do not cause unrelated rerenders', async () => {
		const store = createReduxStore( 'test', {
			reducer: ( state = {}, action ) => {
				switch ( action.type ) {
					case 'RECEIVE':
						return { ...state, [ action.endpoint ]: action.data };
					default:
						return state;
				}
			},
			selectors: {
				getData: ( state, endpoint ) => state[ endpoint ],
			},
			resolvers: {
				getData:
					( endpoint ) =>
					async ( { dispatch } ) => {
						const delay = endpoint === 'slow' ? 30 : 10;
						await new Promise( ( r ) =>
							setTimeout( () => r(), delay )
						);
						dispatch( {
							type: 'RECEIVE',
							endpoint,
							data: endpoint,
						} );
					},
			},
		} );

		const registry = createRegistry();
		registry.register( store );

		const FastUI = vi.fn( () => {
			const data = useSuspenseSelect(
				( select ) => select( store ).getData( 'fast' ),
				[]
			);
			return <div aria-label="fast loaded">{ data }</div>;
		} );

		const SlowUI = vi.fn( () => {
			const data = useSuspenseSelect(
				( select ) => select( store ).getData( 'slow' ),
				[]
			);
			return <div aria-label="slow loaded">{ data }</div>;
		} );

		const App = () => (
			<RegistryProvider value={ registry }>
				<Suspense fallback="fast loading">
					<FastUI />
				</Suspense>
				<Suspense fallback="slow loading">
					<SlowUI />
				</Suspense>
			</RegistryProvider>
		);

		render( <App /> );

		const fastLabel = await screen.findByLabelText( 'fast loaded' );
		expect( fastLabel ).toHaveTextContent( 'fast' );

		const slowLabel = await screen.findByLabelText( 'slow loaded' );
		expect( slowLabel ).toHaveTextContent( 'slow' );

		// Verify the number of renders. The reasoning behind is subtle but not that complicated:
		// - initial render where both `FastUI` and `SlowUI` suspend and fallbacks are rendered;
		// - second (async) render for sibling prerendering that tries to collect all suspending promises.
		//   Both `FastUI` and `SlowUI` suspend again but this time the result is not committed, its purpose
		//   was just to collect suspending promises.
		// - `FastUI` renders for third time after its suspended promise is resolved. However, this render
		//   is _not_ committed because of "suspense fallback throttling". The render is delayed/throttled for 300ms
		//   so that the user doesn't see too many and too fast UI flashes. The fallback continues to be rendered.
		// - `SlowUI` suspended promise is resolved and a third render is triggered. However, as the `FastUI` render
		//   is not yet committed, a _fourth_ render is triggered, to make sure that we'll be committing the most
		//   up-to-date state of the `FastUI` component.
		//
		// The above explains why there are 4 renders of `FastUI` and 3 renders of `SlowUI`. The non-obvious part
		// is explained by a combination of two React features:
		// - sibling prerendering that triggers an extra rerender after one component suspends;
		// - fallback throttling that delays commit by 300ms and triggers an extra rerender.
		expect( FastUI ).toHaveBeenCalledTimes( 4 );
		expect( SlowUI ).toHaveBeenCalledTimes( 3 );
	} );
} );
