/**
 * Validates blocks for AMP compatibility.
 *
 * This uses the REST API response from saving a page to find validation errors.
 * If one exists for a block, it display it inline with a Notice component.
 */

/* exported ampBlockValidation */
/* global wp, _ */
var ampBlockValidation = ( function() { // eslint-disable-line no-unused-vars
	'use strict';

	var module = {

		/**
		 * Data exported from server.
		 *
		 * @param {Object}
		 */
		data: {
			i18n: {},
			ampValidityRestField: '',
			isSanitizationAutoAccepted: false
		},

		/**
		 * Name of the store.
		 *
		 * @param {string}
		 */
		storeName: 'amp/blockValidation',

		/**
		 * Holds the last states which are used for comparisons.
		 *
		 * @param {Object}
		 */
		lastStates: {
			noticesAreReset: false,
			validationErrors: [],
			blockOrder: [],
			blockValidationErrors: {}
		},

		/**
		 * Boot module.
		 *
		 * @param {Object} data - Module data.
		 * @return {void}
		 */
		boot: function boot( data ) {
			module.data = data;

			wp.i18n.setLocaleData( module.data.i18n, 'amp' );

			wp.hooks.addFilter(
				'editor.BlockEdit',
				'amp/add-notice',
				module.conditionallyAddNotice,
				99 // eslint-disable-line
			);

			module.store = module.registerStore();

			wp.data.subscribe( module.handleValidationErrorsStateChange );
		},

		/**
		 * Register store.
		 *
		 * @return {Object} Store.
		 */
		registerStore: function registerStore() {
			return wp.data.registerStore( module.storeName, {
				reducer: function( _state, action ) {
					var state = _state || {
						blockValidationErrorsByClientId: {}
					};

					switch ( action.type ) {
						case 'UPDATE_BLOCKS_VALIDATION_ERRORS':
							return _.extend( {}, state, {
								blockValidationErrorsByClientId: action.blockValidationErrorsByClientId
							} );
						default:
							return state;
					}
				},
				actions: {
					updateBlocksValidationErrors: function( blockValidationErrorsByClientId ) {
						return {
							type: 'UPDATE_BLOCKS_VALIDATION_ERRORS',
							blockValidationErrorsByClientId: blockValidationErrorsByClientId
						};
					}
				},
				selectors: {
					getBlockValidationErrors: function( state, clientId ) {
						return state.blockValidationErrorsByClientId[ clientId ] || [];
					}
				}
			} );
		},

		/**
		 * Checks if AMP is enabled for this post.
		 *
		 * @return {boolean} Returns true when the AMP toggle is on; else, false is returned.
		 */
		isAMPEnabled: function isAMPEnabled() {
			var meta = wp.data.select( 'core/editor' ).getEditedPostAttribute( 'meta' );
			if ( meta && meta.amp_status && window.wpAmpEditor.possibleStati.includes( meta.amp_status ) ) {
				return 'enabled' === meta.amp_status;
			}
			return window.wpAmpEditor.defaultStatus;
		},

		/**
		 * Checks if the validate errors state change handler should wait before processing.
		 *
		 * @return {boolean} Whether should wait.
		 */
		waitToHandleStateChange: function waitToHandleStateChange() {
			var currentPost;

			// @todo Gutenberg currently is not persisting isDirty state if changes are made during save request. Block order mismatch.
			// We can only align block validation errors with blocks in editor when in saved state, since only here will the blocks be aligned with the validation errors.
			if ( wp.data.select( 'core/editor' ).isEditedPostDirty() || ( ! wp.data.select( 'core/editor' ).isEditedPostDirty() && wp.data.select( 'core/editor' ).isEditedPostNew() ) ) {
				return true;
			}

			// Wait for the current post to be set up.
			currentPost = wp.data.select( 'core/editor' ).getCurrentPost();
			if ( ! currentPost.hasOwnProperty( 'id' ) ) {
				return true;
			}

			return false;
		},

		/**
		 * Handle state change regarding validation errors.
		 *
		 * This is essentially a JS implementation of \AMP_Validation_Manager::print_edit_form_validation_status() in PHP.
		 *
		 * @return {void}
		 */
		handleValidationErrorsStateChange: function handleValidationErrorsStateChange() {
			var currentPost, validationErrors, blockValidationErrors, noticeOptions, noticeMessage, blockErrorCount, ampValidity, rejectedErrors;

			if ( ! module.isAMPEnabled() ) {
				if ( ! module.lastStates.noticesAreReset ) {
					module.lastStates.validationErrors = [];
					module.lastStates.noticesAreReset = true;
					module.resetWarningNotice();
					module.resetBlockNotices();
				}
				return;
			}

			if ( module.waitToHandleStateChange() ) {
				return;
			}

			currentPost = wp.data.select( 'core/editor' ).getCurrentPost();
			ampValidity = currentPost[ module.data.ampValidityRestField ] || {};

			// Show all validation errors which have not been explicitly acknowledged as accepted.
			validationErrors = _.map(
				_.filter( ampValidity.results, function( result ) {
					// @todo Show VALIDATION_ERROR_ACK_REJECTED_STATUS differently since moderated?
					return (
						0 /* \AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_NEW_REJECTED_STATUS */ === result.status ||
						1 /* \AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_NEW_ACCEPTED_STATUS */ === result.status ||
						2 /* \AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_ACK_REJECTED_STATUS */ === result.status // eslint-disable-line no-magic-numbers
					);
				} ),
				function( result ) {
					return result.error;
				}
			);

			// Short-circuit if there was no change to the validation errors.
			if ( ! module.didValidationErrorsChange( validationErrors ) ) {
				if ( ! validationErrors.length && ! module.lastStates.noticesAreReset ) {
					module.lastStates.noticesAreReset = true;
					module.resetWarningNotice();
				}
				return;
			}
			module.lastStates.validationErrors = validationErrors;
			module.lastStates.noticesAreReset = false;

			// Remove any existing notice.
			module.resetWarningNotice();

			noticeMessage = wp.i18n.sprintf(
				wp.i18n._n(
					'There is %s issue from AMP validation which needs review.',
					'There are %s issues from AMP validation which need review.',
					validationErrors.length,
					'amp'
				),
				validationErrors.length
			);

			try {
				blockValidationErrors = module.getBlocksValidationErrors();
				module.lastStates.blockValidationErrors = blockValidationErrors.byClientId;
				wp.data.dispatch( module.storeName ).updateBlocksValidationErrors( blockValidationErrors.byClientId );

				blockErrorCount = validationErrors.length - blockValidationErrors.other.length;
				if ( blockErrorCount > 0 ) {
					noticeMessage += ' ' + wp.i18n.sprintf(
						/* translators: %s is the count of block errors. */
						wp.i18n._n(
							'And %s is directly due to content here.',
							'And %s are directly due to content here.',
							blockErrorCount,
							'amp'
						),
						blockErrorCount
					);
				} else {
					noticeMessage += ' ' + wp.i18n.sprintf(
						wp.i18n._n(
							'But it is not directly due to content here.',
							'But none are directly due to content here.',
							validationErrors.length,
							'amp'
						),
						validationErrors.length
					);
				}
			} catch ( e ) {
				// Clear out block validation errors in case the block sand errors cannot be aligned.
				module.resetBlockNotices();

				noticeMessage += ' ' + wp.i18n._n(
					'It may not be due to content here.',
					'Some may be due to content here.',
					validationErrors.length,
					'amp'
				);
			}

			rejectedErrors = _.filter( ampValidity.results, function( result ) {
				return (
					0 /* \AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_NEW_REJECTED_STATUS */ === result.status ||
					2 /* \AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_ACK_REJECTED_STATUS */ === result.status // eslint-disable-line no-magic-numbers
				);
			} );

			noticeMessage += ' ';
			// Auto-acceptance is from either checking 'Automatically accept sanitization...' or from being in Native mode.
			if ( module.data.isSanitizationAutoAccepted ) {
				if ( 0 === rejectedErrors.length ) {
					noticeMessage += wp.i18n.__( 'However, your site is configured to automatically accept sanitization of the offending markup.', 'amp' );
				} else {
					noticeMessage += wp.i18n._n(
						'Your site is configured to automatically accept sanitization errors, but this error could be from when auto-acceptance was not selected, or from manually rejecting an error.',
						'Your site is configured to automatically accept sanitization errors, but these errors could be from when auto-acceptance was not selected, or from manually rejecting an error.',
						validationErrors.length,
						'amp'
					);
				}
			} else {
				noticeMessage += wp.i18n.__( 'Non-accepted validation errors prevent AMP from being served, and the user will be redirected to the non-AMP version.', 'amp' );
			}

			noticeOptions = {
				id: 'amp-errors-notice'
			};
			if ( ampValidity.review_link ) {
				noticeOptions.actions = [
					{
						label: wp.i18n.__( 'Review issues', 'amp' ),
						url: ampValidity.review_link
					}
				];
			}

			// Display notice if there were validation errors.
			if ( validationErrors.length > 0 ) {
				wp.data.dispatch( 'core/notices' ).createNotice( 'warning', noticeMessage, noticeOptions );
			}

			module.validationWarningNoticeId = noticeOptions.id;
		},

		/**
		 * Checks if the validation errors have changed.
		 *
		 * @param {Object[]} validationErrors A list of validation errors.
		 * @return {boolean|*} Returns true when the validation errors change.
		 */
		didValidationErrorsChange: function didValidationErrorsChange( validationErrors ) {
			if ( module.areBlocksOutOfSync() ) {
				module.lastStates.validationErrors = [];
			}

			return (
				module.lastStates.validationErrors.length !== validationErrors.length
				||
				( validationErrors && ! _.isEqual( module.lastStates.validationErrors, validationErrors ) )
			);
		},

		/**
		 * Checks if the block order is out of sync.
		 *
		 * Block change on page load and can get out of sync during normal editing and saving processes.  This method gives a check to determine if an "out of sync" condition occurred.
		 *
		 * @return {boolean} Whether out of sync.
		 */
		areBlocksOutOfSync: function areBlocksOutOfSync() {
			var blockOrder = wp.data.select( 'core/editor' ).getBlockOrder();
			if ( module.lastStates.blockOrder.length !== blockOrder.length || ! _.isEqual( module.lastStates.blockOrder, blockOrder ) ) {
				module.lastStates.blockOrder = blockOrder;
				return true;
			}

			return false;
		},

		/**
		 * Resets the validation warning notice.
		 *
		 * @return {void}
		 */
		resetWarningNotice: function resetWarningNotice() {
			if ( module.validationWarningNoticeId ) {
				wp.data.dispatch( 'core/notices' ).removeNotice( module.validationWarningNoticeId );
				module.validationWarningNoticeId = null;
			}
		},

		/**
		 * Resets the block level validation errors.
		 *
		 * @return {void}
		 */
		resetBlockNotices: function resetBlockNotices() {
			wp.data.dispatch( module.storeName ).updateBlocksValidationErrors( {} );
		},

		/**
		 * Get flattened block order.
		 *
		 * @param {Object[]} blocks - List of blocks which maty have nested blocks inside them.
		 * @return {string[]} Block IDs in flattened order.
		 */
		getFlattenedBlockOrder: function getFlattenedBlockOrder( blocks ) {
			var blockOrder = [];
			_.each( blocks, function( block ) {
				blockOrder.push( block.clientId );
				if ( block.innerBlocks.length > 0 ) {
					Array.prototype.push.apply( blockOrder, module.getFlattenedBlockOrder( block.innerBlocks ) );
				}
			} );
			return blockOrder;
		},

		/**
		 * Update blocks' validation errors in the store.
		 *
		 * @return {Object} Validation errors grouped by block ID other ones.
		 */
		getBlocksValidationErrors: function getBlocksValidationErrors() {
			var acceptedStatus, blockValidationErrorsByClientId, editorSelect, currentPost, blockOrder, validationErrors, otherValidationErrors;
			acceptedStatus = 3; // eslint-disable-line no-magic-numbers
			editorSelect = wp.data.select( 'core/editor' );
			currentPost = editorSelect.getCurrentPost();
			validationErrors = _.map(
				_.filter( currentPost[ module.data.ampValidityRestField ].results, function( result ) {
					return result.term_status !== acceptedStatus; // If not accepted by the user.
				} ),
				function( result ) {
					return result.error;
				}
			);
			blockOrder = module.getFlattenedBlockOrder( editorSelect.getBlocks() );

			otherValidationErrors = [];
			blockValidationErrorsByClientId = {};
			_.each( blockOrder, function( clientId ) {
				blockValidationErrorsByClientId[ clientId ] = [];
			} );

			_.each( validationErrors, function( validationError ) {
				var i, source, clientId, block, matched;
				if ( ! validationError.sources ) {
					otherValidationErrors.push( validationError );
					return;
				}

				// Find the inner-most nested block source only; ignore any nested blocks.
				matched = false;
				for ( i = validationError.sources.length - 1; 0 <= i; i-- ) {
					source = validationError.sources[ i ];

					// Skip sources that are not for blocks.
					if ( ! source.block_name || _.isUndefined( source.block_content_index ) || currentPost.id !== source.post_id ) {
						continue;
					}

					// Look up the block ID by index, assuming the blocks of content in the editor are the same as blocks rendered on frontend.
					clientId = blockOrder[ source.block_content_index ];
					if ( _.isUndefined( clientId ) ) {
						throw new Error( 'undefined_block_index' );
					}

					// Sanity check that block exists for clientId.
					block = editorSelect.getBlock( clientId );
					if ( ! block ) {
						throw new Error( 'block_lookup_failure' );
					}

					// Check the block type in case a block is dynamically added/removed via the_content filter to cause alignment error.
					if ( block.name !== source.block_name ) {
						throw new Error( 'ordered_block_alignment_mismatch' );
					}

					blockValidationErrorsByClientId[ clientId ].push( validationError );
					matched = true;

					// Stop looking for sources, since we aren't looking for parent blocks.
					break;
				}

				if ( ! matched ) {
					otherValidationErrors.push( validationError );
				}
			} );

			return {
				byClientId: blockValidationErrorsByClientId,
				other: otherValidationErrors
			};
		},

		/**
		 * Get message for validation error.
		 *
		 * @param {Object} validationError - Validation error.
		 * @param {string} validationError.code - Validation error code.
		 * @param {string} [validationError.node_name] - Node name.
		 * @param {string} [validationError.message] - Validation error message.
		 * @return {wp.element.Component[]|string[]} Validation error message.
		 */
		getValidationErrorMessage: function getValidationErrorMessage( validationError ) {
			if ( validationError.message ) {
				return validationError.message;
			}
			if ( 'invalid_element' === validationError.code && validationError.node_name ) {
				return [
					wp.i18n.__( 'Invalid element: ' ),
					wp.element.createElement( 'code', { key: 'name' }, validationError.node_name )
				];
			} else if ( 'invalid_attribute' === validationError.code && validationError.node_name ) {
				return [
					wp.i18n.__( 'Invalid attribute: ' ),
					wp.element.createElement( 'code', { key: 'name' }, validationError.parent_name ? wp.i18n.sprintf( '%s[%s]', validationError.parent_name, validationError.node_name ) : validationError.node_name )
				];
			}
			return [
				wp.i18n.__( 'Error code: ', 'amp' ),
				wp.element.createElement( 'code', { key: 'name' }, validationError.code || wp.i18n.__( 'unknown' ) )
			];
		},

		/**
		 * Wraps the edit() method of a block, and conditionally adds a Notice.
		 *
		 * @param {Function} BlockEdit - The original edit() method of the block.
		 * @return {Function} The edit() method, conditionally wrapped in a notice for AMP validation error(s).
		 */
		conditionallyAddNotice: function conditionallyAddNotice( BlockEdit ) {
			return function( ownProps ) {
				var validationErrors,
					mergedProps;
				function AmpNoticeBlockEdit( props ) {
					var edit, details;
					edit = wp.element.createElement(
						BlockEdit,
						props
					);

					if ( 0 === props.ampBlockValidationErrors.length ) {
						return edit;
					}

					details = wp.element.createElement( 'details', { className: 'amp-block-validation-errors' }, [
						wp.element.createElement( 'summary', { key: 'summary', className: 'amp-block-validation-errors__summary' }, wp.i18n.sprintf(
							wp.i18n._n(
								'There is %s issue from AMP validation.',
								'There are %s issues from AMP validation.',
								props.ampBlockValidationErrors.length,
								'amp'
							),
							props.ampBlockValidationErrors.length
						) ),
						wp.element.createElement(
							'ul',
							{ key: 'list', className: 'amp-block-validation-errors__list' },
							_.map( props.ampBlockValidationErrors, function( error, key ) {
								return wp.element.createElement( 'li', { key: key }, module.getValidationErrorMessage( error ) );
							} )
						)
					] );

					return wp.element.createElement(
						wp.element.Fragment, {},
						wp.element.createElement(
							wp.components.Notice,
							{
								status: 'warning',
								isDismissible: false
							},
							details
						),
						edit
					);
				}

				if ( ! module.lastStates.blockValidationErrors[ ownProps.clientId ] ) {
					validationErrors = wp.data.select( module.storeName ).getBlockValidationErrors( ownProps.clientId );
					module.lastStates.blockValidationErrors[ ownProps.clientId ] = validationErrors;
				}

				mergedProps = _.extend( {}, ownProps, {
					ampBlockValidationErrors: module.lastStates.blockValidationErrors[ ownProps.clientId ]
				} );

				return AmpNoticeBlockEdit( mergedProps );
			};
		}
	};

	return module;
}() );
