/**
 * Internal dependencies
 */
import useRefEffect from '../use-ref-effect';
import useEvent from '../use-event';
import type { UseDropZoneProps } from './types';

/**
 * A hook to facilitate drag and drop handling.
 *
 * @param {UseDropZoneProps} props Hook options
 * @return Ref callback to be passed to the drop zone element.
 */
export default function useDropZone( {
	dropZoneElement,
	isDisabled,
	onDrop: _onDrop,
	onDragStart: _onDragStart,
	onDragEnter: _onDragEnter,
	onDragLeave: _onDragLeave,
	onDragEnd: _onDragEnd,
	onDragOver: _onDragOver,
}: UseDropZoneProps ): React.RefCallback< HTMLElement > {
	const onDropEvent = useEvent( _onDrop );
	const onDragStartEvent = useEvent( _onDragStart );
	const onDragEnterEvent = useEvent( _onDragEnter );
	const onDragLeaveEvent = useEvent( _onDragLeave );
	const onDragEndEvent = useEvent( _onDragEnd );
	const onDragOverEvent = useEvent( _onDragOver );

	return useRefEffect(
		( elem: HTMLElement ) => {
			if ( isDisabled ) {
				return;
			}

			// If a custom dropZoneRef is passed, use that instead of the element.
			// This allows the dropzone to cover an expanded area, rather than
			// be restricted to the area of the ref returned by this hook.
			const element = dropZoneElement ?? elem;

			let isDragging = false;

			const { ownerDocument } = element;

			/**
			 * Checks if an element is in the drop zone.
			 *
			 * @param {EventTarget|null} targetToCheck
			 *
			 * @return {boolean} True if in drop zone, false if not.
			 */
			function isElementInZone(
				targetToCheck: EventTarget | null
			): boolean {
				const { defaultView } = ownerDocument;

				if (
					! targetToCheck ||
					! defaultView ||
					! ( targetToCheck instanceof defaultView.HTMLElement ) ||
					! element.contains( targetToCheck )
				) {
					return false;
				}

				let elementToCheck: HTMLElement | null = targetToCheck;

				do {
					if ( elementToCheck.dataset.isDropZone ) {
						return elementToCheck === element;
					}
				} while ( ( elementToCheck = elementToCheck.parentElement ) );

				return false;
			}

			function maybeDragStart( event: DragEvent ): void {
				if ( isDragging ) {
					return;
				}

				isDragging = true;

				// Note that `dragend` doesn't fire consistently for file and
				// HTML drag events where the drag origin is outside the browser
				// window. In Firefox it may also not fire if the originating
				// node is removed.
				ownerDocument.addEventListener( 'dragend', maybeDragEnd );
				ownerDocument.addEventListener( 'mousemove', maybeDragEnd );

				if ( _onDragStart ) {
					onDragStartEvent( event );
				}
			}

			function onDragEnter( event: DragEvent ): void {
				event.preventDefault();

				// The `dragenter` event will also fire when entering child
				// elements, but we only want to call `onDragEnter` when
				// entering the drop zone, which means the `relatedTarget`
				// (element that has been left) should be outside the drop zone.
				if ( element.contains( event.relatedTarget as Node ) ) {
					return;
				}

				if ( _onDragEnter ) {
					onDragEnterEvent( event );
				}
			}

			function onDragOver( event: DragEvent ): void {
				if ( ! event.defaultPrevented && _onDragOver ) {
					onDragOverEvent( event );
				}

				event.preventDefault();
			}

			function onDragLeave( event: DragEvent ): void {
				// The `dragleave` event will also fire when leaving child
				// elements, but we only want to call `onDragLeave` when
				// leaving the drop zone, which means the `relatedTarget`
				// (element that has been entered) should be outside the drop
				// zone.
				// Note: This is not entirely reliable in Safari due to this bug
				// https://bugs.webkit.org/show_bug.cgi?id=66547

				if ( isElementInZone( event.relatedTarget ) ) {
					return;
				}

				if ( _onDragLeave ) {
					onDragLeaveEvent( event );
				}
			}

			function onDrop( event: DragEvent ): void {
				// Don't handle drop if an inner drop zone already handled it.
				if ( event.defaultPrevented ) {
					return;
				}

				// Prevent the browser default while also signalling to parent
				// drop zones that `onDrop` is already handled.
				event.preventDefault();

				// This seemingly useless line has been shown to resolve a
				// Safari issue where files dragged directly from the dock are
				// not recognized.
				// eslint-disable-next-line no-unused-expressions
				event.dataTransfer && event.dataTransfer.files.length;

				if ( _onDrop ) {
					onDropEvent( event );
				}

				maybeDragEnd( event );
			}

			function maybeDragEnd( event: Event ): void {
				if ( ! isDragging ) {
					return;
				}

				isDragging = false;

				ownerDocument.removeEventListener( 'dragend', maybeDragEnd );
				ownerDocument.removeEventListener( 'mousemove', maybeDragEnd );

				if ( _onDragEnd ) {
					onDragEndEvent( event as MouseEvent );
				}
			}

			element.setAttribute( 'data-is-drop-zone', 'true' );
			element.addEventListener( 'drop', onDrop );
			element.addEventListener( 'dragenter', onDragEnter );
			element.addEventListener( 'dragover', onDragOver );
			element.addEventListener( 'dragleave', onDragLeave );
			// The `dragstart` event doesn't fire if the drag started outside
			// the document.
			ownerDocument.addEventListener( 'dragenter', maybeDragStart );

			return () => {
				element.removeAttribute( 'data-is-drop-zone' );
				element.removeEventListener( 'drop', onDrop );
				element.removeEventListener( 'dragenter', onDragEnter );
				element.removeEventListener( 'dragover', onDragOver );
				element.removeEventListener( 'dragleave', onDragLeave );
				ownerDocument.removeEventListener( 'dragend', maybeDragEnd );
				ownerDocument.removeEventListener( 'mousemove', maybeDragEnd );
				ownerDocument.removeEventListener(
					'dragenter',
					maybeDragStart
				);
			};
		},
		[ isDisabled, dropZoneElement ] // Refresh when the passed in dropZoneElement changes.
	);
}
