/**
 * External dependencies
 */
import { mount } from 'enzyme';

/**
 * WordPress dependencies
 */
import { compose } from '@wordpress/element';

/**
 * Internal dependencies
 */
import {
	registerStore,
	registerReducer,
	registerSelectors,
	registerResolvers,
	registerActions,
	dispatch,
	select,
	withSelect,
	withDispatch,
	subscribe,
	isActionLike,
	isAsyncIterable,
	isIterable,
	toAsyncIterable,
} from '../';

jest.mock( '@wordpress/utils', () => ( {
	deprecated: jest.fn(),
} ) );

describe( 'registerStore', () => {
	it( 'should be shorthand for reducer, actions, selectors registration', () => {
		const store = registerStore( 'butcher', {
			reducer( state = { ribs: 6, chicken: 4 }, action ) {
				switch ( action.type ) {
					case 'sale':
						return {
							...state,
							[ action.meat ]: state[ action.meat ] / 2,
						};
				}

				return state;
			},
			selectors: {
				getPrice: ( state, meat ) => state[ meat ],
			},
			actions: {
				startSale: ( meat ) => ( { type: 'sale', meat } ),
			},
		} );

		expect( store.getState() ).toEqual( { ribs: 6, chicken: 4 } );
		expect( dispatch( 'butcher' ) ).toHaveProperty( 'startSale' );
		expect( select( 'butcher' ) ).toHaveProperty( 'getPrice' );
		expect( select( 'butcher' ).getPrice( 'chicken' ) ).toBe( 4 );
		expect( select( 'butcher' ).getPrice( 'ribs' ) ).toBe( 6 );
		dispatch( 'butcher' ).startSale( 'chicken' );
		expect( select( 'butcher' ).getPrice( 'chicken' ) ).toBe( 2 );
		expect( select( 'butcher' ).getPrice( 'ribs' ) ).toBe( 6 );
	} );
} );

describe( 'registerReducer', () => {
	it( 'Should append reducers to the state', () => {
		const reducer1 = () => 'chicken';
		const reducer2 = () => 'ribs';

		const store = registerReducer( 'red1', reducer1 );
		expect( store.getState() ).toEqual( 'chicken' );

		const store2 = registerReducer( 'red2', reducer2 );
		expect( store2.getState() ).toEqual( 'ribs' );
	} );
} );

describe( 'registerResolvers', () => {
	const unsubscribes = [];
	afterEach( () => {
		let unsubscribe;
		while ( ( unsubscribe = unsubscribes.shift() ) ) {
			unsubscribe();
		}
	} );

	function subscribeWithUnsubscribe( ...args ) {
		const unsubscribe = subscribe( ...args );
		unsubscribes.push( unsubscribe );
		return unsubscribe;
	}

	it( 'should not do anything for selectors which do not have resolvers', () => {
		registerReducer( 'demo', ( state = 'OK' ) => state );
		registerSelectors( 'demo', {
			getValue: ( state ) => state,
		} );
		registerResolvers( 'demo', {} );

		expect( select( 'demo' ).getValue() ).toBe( 'OK' );
	} );

	it( 'should behave as a side effect for the given selector, with arguments', () => {
		const resolver = jest.fn();

		registerReducer( 'demo', ( state = 'OK' ) => state );
		registerSelectors( 'demo', {
			getValue: ( state ) => state,
		} );
		registerResolvers( 'demo', {
			getValue: resolver,
		} );

		const value = select( 'demo' ).getValue( 'arg1', 'arg2' );
		expect( value ).toBe( 'OK' );
		expect( resolver ).toHaveBeenCalledWith( 'OK', 'arg1', 'arg2' );
		select( 'demo' ).getValue( 'arg1', 'arg2' );
		expect( resolver ).toHaveBeenCalledTimes( 1 );
		select( 'demo' ).getValue( 'arg3', 'arg4' );
		expect( resolver ).toHaveBeenCalledTimes( 2 );
	} );

	it( 'should support the object resolver definition', () => {
		const resolver = jest.fn();

		registerReducer( 'demo', ( state = 'OK' ) => state );
		registerSelectors( 'demo', {
			getValue: ( state ) => state,
		} );
		registerResolvers( 'demo', {
			getValue: { fulfill: resolver },
		} );

		const value = select( 'demo' ).getValue( 'arg1', 'arg2' );
		expect( value ).toBe( 'OK' );
	} );

	it( 'should use isFulfilled definition before calling the side effect', () => {
		const resolver = jest.fn();
		let count = 0;

		registerReducer( 'demo', ( state = 'OK' ) => state );
		registerSelectors( 'demo', {
			getValue: ( state ) => state,
		} );
		registerResolvers( 'demo', {
			getValue: {
				fulfill: ( ...args ) => {
					count++;
					resolver( ...args );
				},
				isFulfilled: () => count > 1,
			},
		} );

		for ( let i = 0; i < 4; i++ ) {
			select( 'demo' ).getValue( 'arg1', 'arg2' );
		}
		expect( resolver ).toHaveBeenCalledTimes( 2 );
	} );

	it( 'should resolve action to dispatch', ( done ) => {
		registerReducer( 'demo', ( state = 'NOTOK', action ) => {
			return action.type === 'SET_OK' ? 'OK' : state;
		} );
		registerSelectors( 'demo', {
			getValue: ( state ) => state,
		} );
		registerResolvers( 'demo', {
			getValue: () => ( { type: 'SET_OK' } ),
		} );

		subscribeWithUnsubscribe( () => {
			try {
				expect( select( 'demo' ).getValue() ).toBe( 'OK' );
				done();
			} catch ( error ) {
				done( error );
			}
		} );

		select( 'demo' ).getValue();
	} );

	it( 'should resolve mixed type action array to dispatch', ( done ) => {
		registerReducer( 'counter', ( state = 0, action ) => {
			return action.type === 'INCREMENT' ? state + 1 : state;
		} );
		registerSelectors( 'counter', {
			getCount: ( state ) => state,
		} );
		registerResolvers( 'counter', {
			getCount: () => [
				{ type: 'INCREMENT' },
				Promise.resolve( { type: 'INCREMENT' } ),
			],
		} );

		subscribeWithUnsubscribe( () => {
			if ( select( 'counter' ).getCount() === 2 ) {
				done();
			}
		} );

		select( 'counter' ).getCount();
	} );

	it( 'should resolve generator action to dispatch', ( done ) => {
		registerReducer( 'demo', ( state = 'NOTOK', action ) => {
			return action.type === 'SET_OK' ? 'OK' : state;
		} );
		registerSelectors( 'demo', {
			getValue: ( state ) => state,
		} );
		registerResolvers( 'demo', {
			* getValue() {
				yield { type: 'SET_OK' };
			},
		} );

		subscribeWithUnsubscribe( () => {
			try {
				expect( select( 'demo' ).getValue() ).toBe( 'OK' );
				done();
			} catch ( error ) {
				done( error );
			}
		} );

		select( 'demo' ).getValue();
	} );

	it( 'should resolve promise action to dispatch', ( done ) => {
		registerReducer( 'demo', ( state = 'NOTOK', action ) => {
			return action.type === 'SET_OK' ? 'OK' : state;
		} );
		registerSelectors( 'demo', {
			getValue: ( state ) => state,
		} );
		registerResolvers( 'demo', {
			getValue: () => Promise.resolve( { type: 'SET_OK' } ),
		} );

		subscribeWithUnsubscribe( () => {
			try {
				expect( select( 'demo' ).getValue() ).toBe( 'OK' );
				done();
			} catch ( error ) {
				done( error );
			}
		} );

		select( 'demo' ).getValue();
	} );

	it( 'should resolve promise non-action to dispatch', ( done ) => {
		let shouldThrow = false;
		registerReducer( 'demo', ( state = 'OK' ) => {
			if ( shouldThrow ) {
				throw 'Should not have dispatched';
			}

			return state;
		} );
		shouldThrow = true;
		registerSelectors( 'demo', {
			getValue: ( state ) => state,
		} );
		registerResolvers( 'demo', {
			getValue: () => Promise.resolve(),
		} );

		select( 'demo' ).getValue();

		process.nextTick( () => {
			done();
		} );
	} );

	it( 'should resolve async iterator action to dispatch', ( done ) => {
		registerReducer( 'counter', ( state = 0, action ) => {
			return action.type === 'INCREMENT' ? state + 1 : state;
		} );
		registerSelectors( 'counter', {
			getCount: ( state ) => state,
		} );
		registerResolvers( 'counter', {
			getCount: async function* () {
				yield { type: 'INCREMENT' };
				yield await Promise.resolve( { type: 'INCREMENT' } );
			},
		} );

		subscribeWithUnsubscribe( () => {
			if ( select( 'counter' ).getCount() === 2 ) {
				done();
			}
		} );

		select( 'counter' ).getCount();
	} );

	it( 'should not dispatch resolved promise action on subsequent selector calls', ( done ) => {
		registerReducer( 'demo', ( state = 'NOTOK', action ) => {
			return action.type === 'SET_OK' && state === 'NOTOK' ? 'OK' : 'NOTOK';
		} );
		registerSelectors( 'demo', {
			getValue: ( state ) => state,
		} );
		registerResolvers( 'demo', {
			getValue: () => Promise.resolve( { type: 'SET_OK' } ),
		} );

		subscribeWithUnsubscribe( () => {
			try {
				expect( select( 'demo' ).getValue() ).toBe( 'OK' );
				done();
			} catch ( error ) {
				done( error );
			}
		} );

		select( 'demo' ).getValue();
		select( 'demo' ).getValue();
	} );
} );

describe( 'select', () => {
	it( 'registers multiple selectors to the public API', () => {
		const store = registerReducer( 'reducer1', () => 'state1' );
		const selector1 = jest.fn( () => 'result1' );
		const selector2 = jest.fn( () => 'result2' );

		registerSelectors( 'reducer1', {
			selector1,
			selector2,
		} );

		expect( select( 'reducer1' ).selector1() ).toEqual( 'result1' );
		expect( selector1 ).toBeCalledWith( store.getState() );

		expect( select( 'reducer1' ).selector2() ).toEqual( 'result2' );
		expect( selector2 ).toBeCalledWith( store.getState() );
	} );
} );

describe( 'withSelect', () => {
	let wrapper;

	const unsubscribes = [];
	afterEach( () => {
		let unsubscribe;
		while ( ( unsubscribe = unsubscribes.shift() ) ) {
			unsubscribe();
		}

		if ( wrapper ) {
			wrapper.unmount();
			wrapper = null;
		}
	} );

	function subscribeWithUnsubscribe( ...args ) {
		const unsubscribe = subscribe( ...args );
		unsubscribes.push( unsubscribe );
		return unsubscribe;
	}

	it( 'passes the relevant data to the component', () => {
		registerReducer( 'reactReducer', () => ( { reactKey: 'reactState' } ) );
		registerSelectors( 'reactReducer', {
			reactSelector: ( state, key ) => state[ key ],
		} );

		// In normal circumstances, the fact that we have to add an arbitrary
		// prefix to the variable name would be concerning, and perhaps an
		// argument that we ought to expect developer to use select from the
		// wp.data export. But in-fact, this serves as a good deterrent for
		// including both `withSelect` and `select` in the same scope, which
		// shouldn't occur for a typical component, and if it did might wrongly
		// encourage the developer to use `select` within the component itself.
		const Component = withSelect( ( _select, ownProps ) => ( {
			data: _select( 'reactReducer' ).reactSelector( ownProps.keyName ),
		} ) )( ( props ) => <div>{ props.data }</div> );

		wrapper = mount( <Component keyName="reactKey" /> );

		// Wrapper is the enhanced component. Find props on the rendered child.
		const child = wrapper.childAt( 0 );
		expect( child.props() ).toEqual( {
			keyName: 'reactKey',
			data: 'reactState',
		} );
		expect( wrapper.text() ).toBe( 'reactState' );
	} );

	it( 'should rerun selection on state changes', () => {
		registerReducer( 'counter', ( state = 0, action ) => {
			if ( action.type === 'increment' ) {
				return state + 1;
			}

			return state;
		} );

		registerSelectors( 'counter', {
			getCount: ( state ) => state,
		} );

		registerActions( 'counter', {
			increment: () => ( { type: 'increment' } ),
		} );

		const Component = compose( [
			withSelect( ( _select ) => ( {
				count: _select( 'counter' ).getCount(),
			} ) ),
			withDispatch( ( _dispatch ) => ( {
				increment: _dispatch( 'counter' ).increment,
			} ) ),
		] )( ( props ) => (
			<button onClick={ props.increment }>
				{ props.count }
			</button>
		) );

		wrapper = mount( <Component /> );

		const button = wrapper.find( 'button' );

		button.simulate( 'click' );

		expect( button.text() ).toBe( '1' );
	} );

	it( 'should rerun selection on props changes', () => {
		registerReducer( 'counter', ( state = 0, action ) => {
			if ( action.type === 'increment' ) {
				return state + 1;
			}

			return state;
		} );

		registerSelectors( 'counter', {
			getCount: ( state, offset ) => state + offset,
		} );

		const Component = withSelect( ( _select, ownProps ) => ( {
			count: _select( 'counter' ).getCount( ownProps.offset ),
		} ) )( ( props ) => <div>{ props.count }</div> );

		wrapper = mount( <Component offset={ 0 } /> );

		wrapper.setProps( { offset: 10 } );

		expect( wrapper.childAt( 0 ).text() ).toBe( '10' );
	} );

	it( 'ensures component is still mounted before setting state', () => {
		// This test verifies that even though unsubscribe doesn't take effect
		// until after the current listener stack is called, we don't attempt
		// to setState on an unmounting `withSelect` component. It will fail if
		// an attempt is made to `setState` on an unmounted component.
		const store = registerReducer( 'counter', ( state = 0, action ) => {
			if ( action.type === 'increment' ) {
				return state + 1;
			}

			return state;
		} );

		registerSelectors( 'counter', {
			getCount: ( state, offset ) => state + offset,
		} );

		subscribeWithUnsubscribe( () => {
			wrapper.unmount();
		} );

		const Component = withSelect( ( _select, ownProps ) => ( {
			count: _select( 'counter' ).getCount( ownProps.offset ),
		} ) )( ( props ) => <div>{ props.count }</div> );

		wrapper = mount( <Component offset={ 0 } /> );

		store.dispatch( { type: 'increment' } );
	} );

	it( 'should not rerun selection on unchanging state', () => {
		const store = registerReducer( 'unchanging', ( state = {} ) => state );

		registerSelectors( 'unchanging', {
			getState: ( state ) => state,
		} );

		const mapSelectToProps = jest.fn();

		const Component = compose( [
			withSelect( mapSelectToProps ),
		] )( () => <div /> );

		wrapper = mount( <Component /> );

		store.dispatch( { type: 'dummy' } );

		expect( mapSelectToProps ).toHaveBeenCalledTimes( 1 );
	} );

	it( 'omits props which are not returned on subsequent mappings', () => {
		registerReducer( 'demo', ( state = 'OK' ) => state );
		registerSelectors( 'demo', {
			getValue: ( state ) => state,
		} );

		const Component = withSelect( ( _select, ownProps ) => {
			return {
				[ ownProps.propName ]: _select( 'demo' ).getValue(),
			};
		} )( () => <div /> );

		wrapper = mount( <Component propName="foo" /> );

		expect( wrapper.childAt( 0 ).props() ).toEqual( { foo: 'OK', propName: 'foo' } );

		wrapper.setProps( { propName: 'bar' } );

		expect( wrapper.childAt( 0 ).props() ).toEqual( { bar: 'OK', propName: 'bar' } );
	} );

	it( 'allows undefined return from mapSelectToProps', () => {
		registerReducer( 'demo', ( state = 'OK' ) => state );
		registerSelectors( 'demo', {
			getValue: ( state ) => state,
		} );

		const Component = withSelect( ( _select, ownProps ) => {
			if ( ownProps.pass ) {
				return {
					count: _select( 'demo' ).getValue(),
				};
			}
		} )( ( props ) => <div>{ props.count || 'Unknown' }</div> );

		wrapper = mount( <Component pass={ false } /> );

		expect( wrapper.childAt( 0 ).text() ).toBe( 'Unknown' );

		wrapper.setProps( { pass: true } );

		expect( wrapper.childAt( 0 ).text() ).toBe( 'OK' );

		wrapper.setProps( { pass: false } );

		expect( wrapper.childAt( 0 ).text() ).toBe( 'Unknown' );
	} );
} );

describe( 'withDispatch', () => {
	let wrapper;
	afterEach( () => {
		if ( wrapper ) {
			wrapper.unmount();
			wrapper = null;
		}
	} );

	it( 'passes the relevant data to the component', () => {
		const store = registerReducer( 'counter', ( state = 0, action ) => {
			if ( action.type === 'increment' ) {
				return state + action.count;
			}
			return state;
		} );

		const increment = ( count = 1 ) => ( { type: 'increment', count } );
		registerActions( 'counter', {
			increment,
		} );

		const Component = withDispatch( ( _dispatch, ownProps ) => {
			const { count } = ownProps;

			return {
				increment: () => _dispatch( 'counter' ).increment( count ),
			};
		} )( ( props ) => <button onClick={ props.increment } /> );

		wrapper = mount( <Component count={ 0 } /> );

		// Wrapper is the enhanced component. Find props on the rendered child.
		const child = wrapper.childAt( 0 );

		const incrementBeforeSetProps = child.prop( 'increment' );

		// Verify that dispatch respects props at the time of being invoked by
		// changing props after the initial mount.
		wrapper.setProps( { count: 2 } );

		// Function value reference should not have changed in props update.
		expect( child.prop( 'increment' ) ).toBe( incrementBeforeSetProps );

		wrapper.find( 'button' ).simulate( 'click' );

		expect( store.getState() ).toBe( 2 );
	} );
} );

describe( 'subscribe', () => {
	const unsubscribes = [];
	afterEach( () => {
		let unsubscribe;
		while ( ( unsubscribe = unsubscribes.shift() ) ) {
			unsubscribe();
		}
	} );

	function subscribeWithUnsubscribe( ...args ) {
		const unsubscribe = subscribe( ...args );
		unsubscribes.push( unsubscribe );
		return unsubscribe;
	}

	it( 'registers multiple selectors to the public API', () => {
		let incrementedValue = null;
		const store = registerReducer( 'myAwesomeReducer', ( state = 0 ) => state + 1 );
		registerSelectors( 'myAwesomeReducer', {
			globalSelector: ( state ) => state,
		} );
		const unsubscribe = subscribe( () => {
			incrementedValue = select( 'myAwesomeReducer' ).globalSelector();
		} );
		const action = { type: 'dummy' };

		store.dispatch( action ); // increment the data by => data = 2
		expect( incrementedValue ).toBe( 2 );

		store.dispatch( action ); // increment the data by => data = 3
		expect( incrementedValue ).toBe( 3 );

		unsubscribe(); // Store subscribe to changes, the data variable stops upgrading.

		store.dispatch( action );
		store.dispatch( action );

		expect( incrementedValue ).toBe( 3 );
	} );

	it( 'snapshots listeners on change, avoiding a later listener if subscribed during earlier callback', () => {
		const store = registerReducer( 'myAwesomeReducer', ( state = 0 ) => state + 1 );
		const secondListener = jest.fn();
		const firstListener = jest.fn( () => {
			subscribeWithUnsubscribe( secondListener );
		} );

		subscribeWithUnsubscribe( firstListener );

		store.dispatch( { type: 'dummy' } );

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

	it( 'snapshots listeners on change, calling a later listener even if unsubscribed during earlier callback', () => {
		const store = registerReducer( 'myAwesomeReducer', ( state = 0 ) => state + 1 );
		const firstListener = jest.fn( () => {
			secondUnsubscribe();
		} );
		const secondListener = jest.fn();

		subscribeWithUnsubscribe( firstListener );
		const secondUnsubscribe = subscribeWithUnsubscribe( secondListener );

		store.dispatch( { type: 'dummy' } );

		expect( secondListener ).toHaveBeenCalled();
	} );

	it( 'does not call listeners if state has not changed', () => {
		const store = registerReducer( 'unchanging', ( state = {} ) => state );
		const listener = jest.fn();
		subscribeWithUnsubscribe( listener );

		store.dispatch( { type: 'dummy' } );

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

describe( 'dispatch', () => {
	it( 'registers actions to the public API', () => {
		const store = registerReducer( 'counter', ( state = 0, action ) => {
			if ( action.type === 'increment' ) {
				return state + action.count;
			}
			return state;
		} );
		const increment = ( count = 1 ) => ( { type: 'increment', count } );
		registerActions( 'counter', {
			increment,
		} );

		dispatch( 'counter' ).increment(); // state = 1
		dispatch( 'counter' ).increment( 4 ); // state = 5
		expect( store.getState() ).toBe( 5 );
	} );
} );

describe( 'isActionLike', () => {
	it( 'returns false if non-action-like', () => {
		expect( isActionLike( undefined ) ).toBe( false );
		expect( isActionLike( null ) ).toBe( false );
		expect( isActionLike( [] ) ).toBe( false );
		expect( isActionLike( {} ) ).toBe( false );
		expect( isActionLike( 1 ) ).toBe( false );
		expect( isActionLike( 0 ) ).toBe( false );
		expect( isActionLike( Infinity ) ).toBe( false );
		expect( isActionLike( { type: null } ) ).toBe( false );
	} );

	it( 'returns true if action-like', () => {
		expect( isActionLike( { type: 'POW' } ) ).toBe( true );
	} );
} );

describe( 'isAsyncIterable', () => {
	it( 'returns false if not async iterable', () => {
		expect( isAsyncIterable( undefined ) ).toBe( false );
		expect( isAsyncIterable( null ) ).toBe( false );
		expect( isAsyncIterable( [] ) ).toBe( false );
		expect( isAsyncIterable( {} ) ).toBe( false );
	} );

	it( 'returns true if async iterable', async () => {
		async function* getAsyncIterable() {
			yield new Promise( ( resolve ) => process.nextTick( resolve ) );
		}

		const result = getAsyncIterable();

		expect( isAsyncIterable( result ) ).toBe( true );

		await result;
	} );
} );

describe( 'isIterable', () => {
	it( 'returns false if not iterable', () => {
		expect( isIterable( undefined ) ).toBe( false );
		expect( isIterable( null ) ).toBe( false );
		expect( isIterable( {} ) ).toBe( false );
		expect( isIterable( Promise.resolve( {} ) ) ).toBe( false );
	} );

	it( 'returns true if iterable', () => {
		function* getIterable() {
			yield 'foo';
		}

		const result = getIterable();

		expect( isIterable( result ) ).toBe( true );
		expect( isIterable( [] ) ).toBe( true );
	} );
} );

describe( 'toAsyncIterable', () => {
	it( 'normalizes async iterable', async () => {
		async function* getAsyncIterable() {
			yield await Promise.resolve( { ok: true } );
		}

		const object = getAsyncIterable();
		const normalized = toAsyncIterable( object );

		expect( ( await normalized.next() ).value ).toEqual( { ok: true } );
	} );

	it( 'normalizes promise', async () => {
		const object = Promise.resolve( { ok: true } );
		const normalized = toAsyncIterable( object );

		expect( ( await normalized.next() ).value ).toEqual( { ok: true } );
	} );

	it( 'normalizes object', async () => {
		const object = { ok: true };
		const normalized = toAsyncIterable( object );

		expect( ( await normalized.next() ).value ).toEqual( { ok: true } );
	} );

	it( 'normalizes array of promise', async () => {
		const object = [ Promise.resolve( { ok: true } ) ];
		const normalized = toAsyncIterable( object );

		expect( ( await normalized.next() ).value ).toEqual( { ok: true } );
	} );

	it( 'normalizes mixed array', async () => {
		const object = [ { foo: 'bar' }, Promise.resolve( { ok: true } ) ];
		const normalized = toAsyncIterable( object );

		expect( ( await normalized.next() ).value ).toEqual( { foo: 'bar' } );
		expect( ( await normalized.next() ).value ).toEqual( { ok: true } );
	} );

	it( 'normalizes generator', async () => {
		function* getIterable() {
			yield Promise.resolve( { ok: true } );
		}

		const object = getIterable();
		const normalized = toAsyncIterable( object );

		expect( ( await normalized.next() ).value ).toEqual( { ok: true } );
	} );
} );
