'use client';
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
import { normalizeHooks } from '@byline/core';
// Vendored nested get/set (see ./nested-path) — removes the lodash-es dep
// outright. A bare `from 'lodash-es'` import otherwise pools into a single
// ~85KB chunk that leaks onto the public frontend bundle (form-context is
// reachable from the layout graph).
import { get as getNestedValue, hasExistingIdTargets, setWithResult as setNestedValue, } from './nested-path';
import { deletePendingUploadsUnderPath } from './pending-uploads';
import { useTrackedSlot } from './use-tracked-slot';
/**
 * Order-insensitive set equality for the advertised-locale slot. The slot
 * holds an array, so a fresh array reference is never `===` its initial — dirty
 * tracking must compare membership, not identity.
 */
const sameLocaleSet = (a, b) => {
    if (a.length !== b.length)
        return false;
    const sa = [...a].sort();
    const sb = [...b].sort();
    return sa.every((v, i) => v === sb[i]);
};
/** Dirty-tracking keys for the two system-managed, document-grain slots. */
const SYSTEM_PATH_DIRTY_KEY = '__systemPath__';
const SYSTEM_AVAILABLE_LOCALES_DIRTY_KEY = '__systemAvailableLocales__';
const FormContext = createContext(null);
export const useFormContext = () => {
    const context = useContext(FormContext);
    if (context == null) {
        throw new Error('useFormContext must be used within a FormProvider');
    }
    return context;
};
export const FormProvider = ({ children, initialData = {}, documentId = null, collectionPath = null, }) => {
    const fieldValues = useRef(JSON.parse(JSON.stringify(initialData?.fields ?? initialData)));
    const initialValues = useRef(initialData?.fields ?? initialData);
    const errorsRef = useRef([]);
    const dirtyFields = useRef(new Set());
    const patchesRef = useRef([]);
    const pendingUploadsRef = useRef(new Map());
    const uploadingFieldsRef = useRef(new Set());
    const uploadingListenersRef = useRef(new Map());
    const fieldListeners = useRef(new Map());
    const errorListeners = useRef(new Set());
    const metaListeners = useRef(new Set());
    const subscribeField = useCallback((name, listener) => {
        if (!fieldListeners.current.has(name)) {
            fieldListeners.current.set(name, new Set());
        }
        fieldListeners.current.get(name)?.add(listener);
        return () => {
            const listeners = fieldListeners.current.get(name);
            if (listeners) {
                listeners.delete(listener);
                if (listeners.size === 0) {
                    fieldListeners.current.delete(name);
                }
            }
        };
    }, []);
    const subscribeErrors = useCallback((listener) => {
        errorListeners.current.add(listener);
        return () => {
            errorListeners.current.delete(listener);
        };
    }, []);
    const subscribeMeta = useCallback((listener) => {
        metaListeners.current.add(listener);
        return () => {
            metaListeners.current.delete(listener);
        };
    }, []);
    const notifyFieldListeners = useCallback((name, value) => {
        const listeners = fieldListeners.current.get(name);
        if (listeners) {
            listeners.forEach((listener) => {
                listener(value);
            });
        }
    }, []);
    const notifyErrorListeners = useCallback(() => {
        errorListeners.current.forEach((listener) => {
            listener(errorsRef.current);
        });
    }, []);
    const notifyMetaListeners = useCallback(() => {
        metaListeners.current.forEach((listener) => {
            listener();
        });
    }, []);
    // Document-grain system-field slots — dirty-tracked, ref-backed, each with
    // its own listener set. The `path` slot is initialised from the loaded
    // version's top-level `path` (edit) or `null` (create); the available-locales
    // slot from `availableLocales` (edit) or `[]`. Edits toggle the slot's dirty
    // key so the single Save button can branch. See ./use-tracked-slot.
    const pathSlot = useTrackedSlot({
        initial: typeof initialData?.path === 'string' && initialData.path.length > 0
            ? initialData.path
            : null,
        dirtyKey: SYSTEM_PATH_DIRTY_KEY,
        dirtyFields,
        notifyMeta: notifyMetaListeners,
    });
    const availableLocalesSlot = useTrackedSlot({
        initial: Array.isArray(initialData?.availableLocales) ? [...initialData.availableLocales] : [],
        dirtyKey: SYSTEM_AVAILABLE_LOCALES_DIRTY_KEY,
        dirtyFields,
        notifyMeta: notifyMetaListeners,
        // The slot holds an array; a fresh reference is never `===` its baseline,
        // so dirty tracking compares membership, not identity. Stored as a copy.
        isEqual: sameLocaleSet,
        clone: (value) => [...value],
    });
    const updateFieldStoreInternal = useCallback((name, value) => {
        const newFieldValues = { ...fieldValues.current };
        // Keep nested path values up to date for generic usage and patches.
        if (!setNestedValue(newFieldValues, name, value))
            return false;
        fieldValues.current = newFieldValues;
        dirtyFields.current.add(name);
        notifyFieldListeners(name, value);
        notifyMetaListeners();
        return true;
    }, [notifyFieldListeners, notifyMetaListeners]);
    const setFieldStore = useCallback((name, value) => {
        updateFieldStoreInternal(name, value);
    }, [updateFieldStoreInternal]);
    const setFieldValue = useCallback((name, value) => {
        if (!updateFieldStoreInternal(name, value))
            return;
        const patch = {
            kind: 'field.set',
            path: name,
            value,
        };
        // Optimization: Coalesce consecutive field.set patches for the same path
        const lastPatch = patchesRef.current[patchesRef.current.length - 1];
        if (lastPatch && lastPatch.kind === 'field.set' && lastPatch.path === name) {
            const newPatches = [...patchesRef.current];
            newPatches[newPatches.length - 1] = patch;
            patchesRef.current = newPatches;
        }
        else {
            patchesRef.current = [...patchesRef.current, patch];
        }
        // Clear field-specific errors when value changes
        if (errorsRef.current.some((error) => error.field === name)) {
            errorsRef.current = errorsRef.current.filter((error) => error.field !== name);
            notifyErrorListeners();
        }
    }, [updateFieldStoreInternal, notifyErrorListeners]);
    const getFieldValues = useCallback(() => fieldValues.current, []);
    const getPatches = useCallback(() => patchesRef.current, []);
    const appendPatch = useCallback((patch) => {
        // Snapshot the patch at append time. Structural patches (array.insert,
        // block add) carry item objects that are ALSO placed into the form
        // store — and `setNestedValue` mutates store nodes in place, so a
        // later nested write inside the item (e.g. adding an array item to a
        // block added this session) would silently rewrite the queued patch.
        // Serialized at save time, the block insert would then already contain
        // the array items AND the array.insert patches would re-add them —
        // duplicating items server-side (caught by e2e/array-in-block.spec.ts).
        patchesRef.current = [...patchesRef.current, structuredClone(patch)];
        // Mark a generic dirty flag so hasChanges() becomes true even
        // for patches that don't correspond to a specific field.set.
        dirtyFields.current.add('__patch__');
        notifyMetaListeners();
        // Dev-time patch tracing — uncomment when debugging the patch stream.
        // if (process.env.NODE_ENV !== 'production') {
        //   // eslint-disable-next-line no-console
        //   console.debug('FormContext.appendPatch', { patch, dirtyCount: dirtyFields.current.size })
        // }
    }, [notifyMetaListeners]);
    const getFieldValue = useCallback((name) => {
        const dirty = dirtyFields.current.has(name);
        const currentValue = getNestedValue(fieldValues.current, name);
        if (currentValue !== undefined) {
            return currentValue;
        }
        if (!dirty) {
            return getNestedValue(initialValues.current, name);
        }
        return undefined;
    }, []);
    const hasChanges = useCallback(() => {
        return dirtyFields.current.size > 0;
    }, []);
    const resetHasChanges = useCallback(() => {
        dirtyFields.current.clear();
        patchesRef.current = [];
        pathSlot.commitInitial();
        availableLocalesSlot.commitInitial();
        notifyMetaListeners();
    }, [notifyMetaListeners, pathSlot.commitInitial, availableLocalesSlot.commitInitial]);
    const isDirty = useCallback((fieldName) => {
        return dirtyFields.current.has(fieldName);
    }, []);
    // Partition the current dirty set by write semantics so the single Save
    // button can route each piece correctly: content → versioned write; the
    // document-grain system fields (path / advertised locales) → immediate,
    // non-versioned direct write that leaves workflow status untouched.
    // See docs/08-internationalization/index.md.
    const getDirtyBreakdown = useCallback(() => {
        const keys = dirtyFields.current;
        const pathDirty = keys.has(SYSTEM_PATH_DIRTY_KEY);
        const availableLocalesDirty = keys.has(SYSTEM_AVAILABLE_LOCALES_DIRTY_KEY);
        let contentDirty = false;
        for (const key of keys) {
            if (key !== SYSTEM_PATH_DIRTY_KEY && key !== SYSTEM_AVAILABLE_LOCALES_DIRTY_KEY) {
                contentDirty = true;
                break;
            }
        }
        const directWrite = pathDirty || availableLocalesDirty;
        const reason = contentDirty && directWrite
            ? 'both'
            : contentDirty
                ? 'content'
                : directWrite
                    ? 'direct-write'
                    : 'none';
        return { reason, contentDirty, pathDirty, availableLocalesDirty };
    }, []);
    // ---------------------------------------------------------------------------
    // Pending uploads (deferred until save)
    // ---------------------------------------------------------------------------
    const addPendingUpload = useCallback((fieldPath, upload) => {
        // Image metadata extraction is asynchronous. If its containing item was
        // removed while decoding, discard the late registration rather than
        // allowing submit to recreate or overwrite an item through a stale path.
        if (!hasExistingIdTargets(fieldValues.current, fieldPath)) {
            URL.revokeObjectURL(upload.previewUrl);
            return false;
        }
        // If there's an existing pending upload for this path, revoke its blob URL
        const existing = pendingUploadsRef.current.get(fieldPath);
        if (existing) {
            URL.revokeObjectURL(existing.previewUrl);
        }
        pendingUploadsRef.current.set(fieldPath, upload);
        dirtyFields.current.add(fieldPath);
        notifyMetaListeners();
        return true;
    }, [notifyMetaListeners]);
    const removePendingUpload = useCallback((fieldPath) => {
        const existing = pendingUploadsRef.current.get(fieldPath);
        if (existing) {
            URL.revokeObjectURL(existing.previewUrl);
            pendingUploadsRef.current.delete(fieldPath);
            notifyMetaListeners();
        }
    }, [notifyMetaListeners]);
    const removePendingUploadsUnder = useCallback((itemPath) => {
        const deleted = deletePendingUploadsUnderPath(pendingUploadsRef.current, itemPath, (url) => URL.revokeObjectURL(url));
        if (deleted)
            notifyMetaListeners();
    }, [notifyMetaListeners]);
    const getPendingUploads = useCallback(() => {
        return new Map(pendingUploadsRef.current);
    }, []);
    const hasPendingUploads = useCallback(() => {
        return pendingUploadsRef.current.size > 0;
    }, []);
    const clearPendingUploads = useCallback(() => {
        // Revoke all blob URLs to prevent memory leaks
        for (const upload of pendingUploadsRef.current.values()) {
            URL.revokeObjectURL(upload.previewUrl);
        }
        pendingUploadsRef.current.clear();
    }, []);
    // ---------------------------------------------------------------------------
    // Per-field upload-in-flight tracking
    // ---------------------------------------------------------------------------
    const setFieldUploading = useCallback((fieldPath, uploading) => {
        if (uploading) {
            if (uploadingFieldsRef.current.has(fieldPath))
                return;
            uploadingFieldsRef.current.add(fieldPath);
        }
        else {
            if (!uploadingFieldsRef.current.has(fieldPath))
                return;
            uploadingFieldsRef.current.delete(fieldPath);
        }
        uploadingListenersRef.current.get(fieldPath)?.forEach((listener) => {
            listener(uploading);
        });
    }, []);
    const getIsFieldUploading = useCallback((fieldPath) => {
        return uploadingFieldsRef.current.has(fieldPath);
    }, []);
    const subscribeFieldUploading = useCallback((fieldPath, listener) => {
        let listeners = uploadingListenersRef.current.get(fieldPath);
        if (!listeners) {
            listeners = new Set();
            uploadingListenersRef.current.set(fieldPath, listeners);
        }
        listeners.add(listener);
        return () => {
            const set = uploadingListenersRef.current.get(fieldPath);
            if (set) {
                set.delete(listener);
                if (set.size === 0) {
                    uploadingListenersRef.current.delete(fieldPath);
                }
            }
        };
    }, []);
    // Cleanup blob URLs on unmount
    useEffect(() => {
        return () => {
            for (const upload of pendingUploadsRef.current.values()) {
                URL.revokeObjectURL(upload.previewUrl);
            }
        };
    }, []);
    const validateForm = useCallback((fields) => {
        const formErrors = [];
        const data = getFieldValues();
        for (const field of fields) {
            // Condition-hidden fields are exempt from client-side validation — a
            // field the editor cannot currently see must not block submit. Only
            // top-level fields flow through this walk, and a root-level field's
            // sibling scope is the form data itself (see FieldCondition).
            if (field.condition && !field.condition(data, data))
                continue;
            const value = getFieldValue(field.name);
            // Required field validation
            if (!field.optional && (value == null || value === '')) {
                formErrors.push({
                    field: field.name,
                    message: `${field.label} is required`,
                });
            }
            // Type-specific validation
            if (value != null && value !== '') {
                switch (field.type) {
                    case 'text':
                        if (typeof value !== 'string') {
                            formErrors.push({
                                field: field.name,
                                message: `${field.label} must be text`,
                            });
                        }
                        break;
                    case 'checkbox':
                        if (typeof value !== 'boolean') {
                            formErrors.push({
                                field: field.name,
                                message: `${field.label} must be true or false`,
                            });
                        }
                        break;
                    case 'select':
                        if ('options' in field && field.options) {
                            const validValues = field.options.map((opt) => opt.value);
                            if (!validValues.includes(value)) {
                                formErrors.push({
                                    field: field.name,
                                    message: `${field.label} must be one of: ${validValues.join(', ')}`,
                                });
                            }
                        }
                        break;
                    case 'datetime':
                        if (value instanceof Date === false && typeof value !== 'string') {
                            formErrors.push({
                                field: field.name,
                                message: `${field.label} must be a valid date`,
                            });
                        }
                        break;
                }
            }
            // Custom validate function — applies to all field types including structure fields.
            if (field.validate) {
                const error = field.validate(value, data);
                if (error) {
                    formErrors.push({ field: field.name, message: error });
                }
            }
        }
        errorsRef.current = formErrors;
        notifyErrorListeners();
        return formErrors;
    }, [getFieldValue, getFieldValues, notifyErrorListeners]);
    const clearErrors = useCallback(() => {
        errorsRef.current = [];
        notifyErrorListeners();
    }, [notifyErrorListeners]);
    const setFieldError = useCallback((field, message) => {
        // Replace any existing error for this field, or add a new one
        const filtered = errorsRef.current.filter((e) => e.field !== field);
        filtered.push({ field, message });
        errorsRef.current = filtered;
        notifyErrorListeners();
    }, [notifyErrorListeners]);
    const clearFieldError = useCallback((field) => {
        if (errorsRef.current.some((e) => e.field === field)) {
            errorsRef.current = errorsRef.current.filter((e) => e.field !== field);
            notifyErrorListeners();
        }
    }, [notifyErrorListeners]);
    /**
     * Run `beforeValidate` hooks for every top-level field that defines one.
     * Called at submit time, before `validateForm()`. Hooks may return
     * `{ value }` to auto-populate a field, or `{ error }` to block submit.
     */
    const runFieldHooks = useCallback(async (fields) => {
        const hookErrors = [];
        const data = { ...fieldValues.current };
        for (const field of fields) {
            const fns = normalizeHooks(field.hooks?.beforeValidate);
            if (fns.length === 0)
                continue;
            // Condition-hidden fields skip submit-time hooks, mirroring their
            // exemption from validateForm below.
            if (field.condition && !field.condition(data, data))
                continue;
            const path = field.name;
            const value = getFieldValue(path);
            const ctx = {
                value,
                previousValue: value,
                data,
                path,
                field,
                operation: 'submit',
                setFieldValue,
            };
            try {
                for (const fn of fns) {
                    const result = (await fn(ctx));
                    if (result?.error) {
                        hookErrors.push({ field: path, message: result.error });
                    }
                    if (result?.value !== undefined) {
                        // Auto-populate: write the derived value into the store
                        setFieldValue(path, result.value);
                        // Keep ctx and data snapshot in sync for subsequent hooks
                        ctx.value = result.value;
                        data[path] = result.value;
                    }
                }
            }
            catch (err) {
                const message = err instanceof Error ? err.message : 'Unexpected hook error';
                hookErrors.push({ field: path, message });
            }
        }
        if (hookErrors.length > 0) {
            errorsRef.current = [...errorsRef.current, ...hookErrors];
            notifyErrorListeners();
        }
        return hookErrors;
    }, [getFieldValue, setFieldValue, notifyErrorListeners]);
    return (<FormContext.Provider value={{
            documentId,
            collectionPath,
            setFieldValue,
            setFieldStore,
            getFieldValue,
            getFieldValues,
            getPatches,
            appendPatch,
            resetPatches: () => {
                patchesRef.current = [];
            },
            hasChanges,
            resetHasChanges,
            runFieldHooks,
            validateForm,
            errors: errorsRef.current,
            getErrors: () => errorsRef.current,
            clearErrors,
            setFieldError,
            clearFieldError,
            isDirty,
            getDirtyBreakdown,
            subscribeField,
            subscribeErrors,
            subscribeMeta,
            addPendingUpload,
            removePendingUpload,
            removePendingUploadsUnder,
            getPendingUploads,
            hasPendingUploads,
            clearPendingUploads,
            setFieldUploading,
            getIsFieldUploading,
            subscribeFieldUploading,
            getSystemPath: pathSlot.get,
            setSystemPath: pathSlot.set,
            subscribeSystemPath: pathSlot.subscribe,
            getSystemAvailableLocales: availableLocalesSlot.get,
            setSystemAvailableLocales: availableLocalesSlot.set,
            subscribeSystemAvailableLocales: availableLocalesSlot.subscribe,
        }}>
      {children}
    </FormContext.Provider>);
};
/**
 * Subscribe to the system `path` slot edited by the path widget.
 * Returns the current value (or `null` when no override is set).
 */
export const useSystemPath = () => {
    const { getSystemPath, subscribeSystemPath } = useFormContext();
    const [value, setValue] = useState(() => getSystemPath());
    useEffect(() => {
        return subscribeSystemPath((next) => setValue(next));
    }, [subscribeSystemPath]);
    return value;
};
/**
 * Subscribe to the system `availableLocales` slot edited by the
 * available-locales widget. Returns the current advertised set (or `[]` when
 * nothing is advertised / not yet surfaced).
 */
export const useSystemAvailableLocales = () => {
    const { getSystemAvailableLocales, subscribeSystemAvailableLocales } = useFormContext();
    const [value, setValue] = useState(() => getSystemAvailableLocales());
    useEffect(() => {
        return subscribeSystemAvailableLocales((next) => setValue(next));
    }, [subscribeSystemAvailableLocales]);
    return value;
};
export const useFormStore = () => {
    return useFormContext();
};
export const useFieldError = (name) => {
    const { getErrors, subscribeErrors } = useFormContext();
    // Seed from the live errors ref via getErrors() rather than the context's
    // `errors` snapshot — the snapshot is bound at FormProvider's first render
    // and goes stale as soon as validateForm replaces errorsRef.current. Fields
    // mounted after validation has already run (e.g. switching to a tab whose
    // error badge is non-zero) would otherwise initialise to undefined and miss
    // the existing error until something else fires notifyErrorListeners.
    const [error, setError] = useState(() => getErrors().find((e) => e.field === name)?.message);
    useEffect(() => {
        const unsubscribe = subscribeErrors((currentErrors) => {
            const fieldError = currentErrors.find((e) => e.field === name);
            setError(fieldError?.message);
        });
        return unsubscribe;
    }, [subscribeErrors, name]);
    return error;
};
export const useFormMeta = () => {
    const { hasChanges, subscribeMeta } = useFormContext();
    const [hasChangesValue, setHasChangesValue] = useState(hasChanges());
    useEffect(() => {
        const unsubscribe = subscribeMeta(() => {
            setHasChangesValue(hasChanges());
        });
        return unsubscribe;
    }, [subscribeMeta, hasChanges]);
    return {
        hasChanges: hasChangesValue,
    };
};
export const useIsDirty = (name) => {
    const { isDirty, subscribeMeta } = useFormContext();
    const [dirty, setDirty] = useState(isDirty(name));
    useEffect(() => {
        const unsubscribe = subscribeMeta(() => {
            setDirty(isDirty(name));
        });
        return unsubscribe;
    }, [subscribeMeta, isDirty, name]);
    return dirty;
};
export const useFieldValue = (name) => {
    const { getFieldValue, subscribeField } = useFormContext();
    const [value, setValue] = useState(() => getFieldValue(name));
    useEffect(() => {
        const unsubscribe = subscribeField(name, (nextValue) => {
            setValue(nextValue);
        });
        return unsubscribe;
    }, [subscribeField, name]);
    return value;
};
/**
 * Subscribe to a single field's upload-in-flight state. Returns `true` while
 * the form orchestrator is actively transporting this field's pending upload
 * (between the `setFieldUploading(path, true)` and the matching `false`
 * emitted by the upload executor's progress callback).
 */
export const useIsFieldUploading = (fieldPath) => {
    const { getIsFieldUploading, subscribeFieldUploading } = useFormContext();
    const [uploading, setUploading] = useState(() => getIsFieldUploading(fieldPath));
    useEffect(() => {
        return subscribeFieldUploading(fieldPath, (next) => {
            setUploading(next);
        });
    }, [subscribeFieldUploading, fieldPath]);
    return uploading;
};
