/**
 * Attribute / document history for Morph editors.
 *
 * Uses @jayoncode/object-diff for faithful undo/redo patches,
 * @jayoncode/storage for offline draft persistence, and
 * @jayoncode/browser-lifecycle to flush drafts when the tab hides.
 */

import {
	applyPatch,
	applyPatchWithInverse,
	diff,
	hasChanges,
	patch,
} from '@jayoncode/object-diff';
import { createBrowserLifecycle } from '@jayoncode/browser-lifecycle';
import {
	createLocalStorageAdapter,
	createStorage,
} from '@jayoncode/storage';

const DEFAULT_MAX_ENTRIES = 100;
const DRAFT_TTL = { days: 7 };
const STORAGE_NAMESPACE = 'joc-blockora-morph.history';

/**
 * @param {unknown} value
 * @return {unknown}
 */
export function cloneState( value ) {
	if ( value == null || typeof value !== 'object' ) {
		return value;
	}
	try {
		return structuredClone( value );
	} catch ( e ) {
		try {
			return JSON.parse( JSON.stringify( value ) );
		} catch ( err ) {
			return value;
		}
	}
}

/**
 * @param {KeyboardEvent} event
 * @return {boolean}
 */
export function isUndoKeyEvent( event ) {
	if ( ! event || event.defaultPrevented ) {
		return false;
	}
	const key = String( event.key || '' ).toLowerCase();
	const mod = event.metaKey || event.ctrlKey;
	return Boolean( mod && ! event.altKey && ! event.shiftKey && key === 'z' );
}

/**
 * @param {KeyboardEvent} event
 * @return {boolean}
 */
export function isRedoKeyEvent( event ) {
	if ( ! event || event.defaultPrevented ) {
		return false;
	}
	const key = String( event.key || '' ).toLowerCase();
	const mod = event.metaKey || event.ctrlKey;
	if ( ! mod || event.altKey ) {
		return false;
	}
	if ( key === 'y' && ! event.shiftKey ) {
		return true;
	}
	return key === 'z' && event.shiftKey;
}

/**
 * True when the event target is a native text field (not Morph CE).
 *
 * @param {EventTarget|null} target
 * @return {boolean}
 */
export function isNativeTextInput( target ) {
	if ( ! target || typeof target !== 'object' ) {
		return false;
	}
	const el = /** @type {HTMLElement} */ ( target );
	const tag = String( el.tagName || '' ).toLowerCase();
	if ( tag === 'textarea' ) {
		return true;
	}
	if ( tag === 'input' ) {
		const type = String( el.getAttribute?.( 'type' ) || 'text' ).toLowerCase();
		return ! [
			'button',
			'checkbox',
			'color',
			'file',
			'hidden',
			'image',
			'radio',
			'range',
			'reset',
			'submit',
		].includes( type );
	}
	return false;
}

/**
 * Morph preview / inspector contenteditable surfaces.
 *
 * @param {EventTarget|null} target
 * @return {boolean}
 */
export function isMorphContentEditTarget( target ) {
	if ( ! target || typeof target !== 'object' ) {
		return false;
	}
	const el = /** @type {HTMLElement} */ ( target );
	if ( el.isContentEditable ) {
		return true;
	}
	return Boolean(
		el.closest?.(
			'[data-bm-content-edit], .bm-preview-content-edit, .bm-text-wysiwyg__surface'
		)
	);
}

/**
 * @param {object} [options]
 * @param {string} [options.storageKey]
 * @param {number} [options.maxEntries]
 * @param {() => object} [options.getState]
 * @param {(next: object) => void} [options.applyState]
 * @param {boolean} [options.persist]
 */
export function createAttributeHistory( options = {} ) {
	const maxEntries = Math.max(
		1,
		Number( options.maxEntries ) || DEFAULT_MAX_ENTRIES
	);
	const persist = options.persist !== false && Boolean( options.storageKey );
	const storageKey = options.storageKey
		? String( options.storageKey )
		: '';

	/** @type {{ forward: unknown, inverse: unknown }[]} */
	let past = [];
	/** @type {{ forward: unknown, inverse: unknown }[]} */
	let future = [];
	let applying = false;
	let disposed = false;
	/** @type {object|null} */
	let batchBase = null;
	let listeners = new Set();
	let persistTimer = 0;

	let getState =
		typeof options.getState === 'function'
			? options.getState
			: () => ( {} );
	let applyState =
		typeof options.applyState === 'function'
			? options.applyState
			: () => {};

	const storage = persist
		? createStorage( {
				namespace: STORAGE_NAMESPACE,
				adapter: createLocalStorageAdapter(),
				schemaVersion: '1',
				ttl: DRAFT_TTL,
		  } )
		: null;

	const notify = () => {
		listeners.forEach( ( listener ) => {
			try {
				listener( api.getMeta() );
			} catch ( e ) {
				// ignore subscriber errors
			}
		} );
	};

	const writeDraft = () => {
		if ( ! storage || ! storageKey || disposed ) {
			return;
		}
		try {
			storage.set(
				storageKey,
				{
					past,
					future,
					snapshot: cloneState( getState() ),
					updatedAt: Date.now(),
				},
				{ ttl: DRAFT_TTL }
			);
		} catch ( e ) {
			// Quota / private mode — history still works in-memory.
		}
	};

	const schedulePersist = () => {
		if ( ! storage || ! storageKey ) {
			return;
		}
		if ( persistTimer ) {
			window.clearTimeout( persistTimer );
		}
		persistTimer = window.setTimeout( () => {
			persistTimer = 0;
			writeDraft();
		}, 250 );
	};

	const trimPast = () => {
		while ( past.length > maxEntries ) {
			past.shift();
		}
	};

	/**
	 * @param {object} before
	 * @param {object} after
	 */
	const record = ( before, after ) => {
		if ( applying || disposed ) {
			return false;
		}
		if ( ! hasChanges( before, after ) ) {
			return false;
		}
		try {
			const forward = patch( diff( before, after ) );
			const { inverse } = applyPatchWithInverse(
				cloneState( before ),
				forward
			);
			past.push( { forward, inverse } );
			future = [];
			trimPast();
			schedulePersist();
			notify();
			return true;
		} catch ( e ) {
			return false;
		}
	};

	const lifecycle =
		typeof window !== 'undefined' && persist
			? createBrowserLifecycle()
			: null;

	let offHidden = null;
	if ( lifecycle ) {
		try {
			lifecycle.start();
			offHidden = lifecycle.on( 'page:hidden', () => {
				writeDraft();
			} );
		} catch ( e ) {
			// Environments without full browser APIs.
		}
	}

	const api = {
		/**
		 * @param {{ getState?: () => object, applyState?: (next: object) => void }} accessors
		 */
		setAccessors( accessors = {} ) {
			if ( typeof accessors.getState === 'function' ) {
				getState = accessors.getState;
			}
			if ( typeof accessors.applyState === 'function' ) {
				applyState = accessors.applyState;
			}
		},

		/**
		 * Start coalescing rapid edits into one undo step.
		 *
		 * @param {object} [base]
		 */
		beginBatch( base ) {
			if ( applying || disposed ) {
				return;
			}
			batchBase = cloneState(
				base != null ? base : getState()
			);
		},

		/**
		 * End a batch and record one patch from beginBatch → current/after.
		 *
		 * @param {object} [after]
		 */
		endBatch( after ) {
			if ( applying || disposed || batchBase == null ) {
				batchBase = null;
				return false;
			}
			const next = after != null ? after : getState();
			const base = batchBase;
			batchBase = null;
			return record( base, next );
		},

		cancelBatch() {
			batchBase = null;
		},

		/**
		 * @param {object} before
		 * @param {object} after
		 */
		record( before, after ) {
			if ( batchBase != null ) {
				// Still inside a batch — defer until endBatch.
				return false;
			}
			return record( before, after );
		},

		canUndo() {
			return past.length > 0;
		},

		canRedo() {
			return future.length > 0;
		},

		getMeta() {
			return {
				canUndo: past.length > 0,
				canRedo: future.length > 0,
				pastSize: past.length,
				futureSize: future.length,
			};
		},

		undo() {
			if ( applying || disposed || ! past.length ) {
				return false;
			}
			const entry = past.pop();
			const current = cloneState( getState() );
			try {
				applying = true;
				const previous = applyPatch( current, entry.inverse );
				future.push( entry );
				applyState( previous );
				schedulePersist();
				notify();
				return true;
			} catch ( e ) {
				past.push( entry );
				return false;
			} finally {
				applying = false;
			}
		},

		redo() {
			if ( applying || disposed || ! future.length ) {
				return false;
			}
			const entry = future.pop();
			const current = cloneState( getState() );
			try {
				applying = true;
				const next = applyPatch( current, entry.forward );
				past.push( entry );
				applyState( next );
				schedulePersist();
				notify();
				return true;
			} catch ( e ) {
				future.push( entry );
				return false;
			} finally {
				applying = false;
			}
		},

		clear() {
			past = [];
			future = [];
			batchBase = null;
			if ( storage && storageKey ) {
				try {
					storage.remove( storageKey );
				} catch ( e ) {
					// ignore
				}
			}
			notify();
		},

		/**
		 * Restore draft stacks from storage (does not apply snapshot).
		 *
		 * @return {object|null} saved snapshot if present
		 */
		restoreDraft() {
			if ( ! storage || ! storageKey ) {
				return null;
			}
			try {
				const draft = storage.get( storageKey );
				if ( ! draft || typeof draft !== 'object' ) {
					return null;
				}
				past = Array.isArray( draft.past ) ? draft.past : [];
				future = Array.isArray( draft.future ) ? draft.future : [];
				trimPast();
				notify();
				return draft.snapshot && typeof draft.snapshot === 'object'
					? draft.snapshot
					: null;
			} catch ( e ) {
				return null;
			}
		},

		flush() {
			writeDraft();
		},

		subscribe( listener ) {
			if ( typeof listener !== 'function' ) {
				return () => {};
			}
			listeners.add( listener );
			return () => {
				listeners.delete( listener );
			};
		},

		isApplying() {
			return applying;
		},

		dispose() {
			disposed = true;
			if ( persistTimer ) {
				window.clearTimeout( persistTimer );
				persistTimer = 0;
			}
			writeDraft();
			if ( typeof offHidden === 'function' ) {
				offHidden();
				offHidden = null;
			}
			if ( lifecycle ) {
				try {
					lifecycle.stop();
					lifecycle.dispose();
				} catch ( e ) {
					// ignore
				}
			}
			listeners = new Set();
		},
	};

	return api;
}
