import Base                          from '../core/Base.mjs';
import ClassSystemUtil               from '../util/ClassSystem.mjs';
import Config                        from '../core/Config.mjs';
import Effect                        from '../core/Effect.mjs';
import EffectManager                 from '../core/EffectManager.mjs';
import Observable                    from '../core/Observable.mjs';
import {createHierarchicalDataProxy} from './createHierarchicalDataProxy.mjs';
import {isDescriptor}                from '../core/ConfigSymbols.mjs';

const
    recordChangeBindings = new WeakMap(),
    twoWayBindingSymbol  = Symbol.for('twoWayBinding');

/**
 * Returns the shared notifyChange wrapper state for a Record instance.
 * @summary Lets multiple StateProviders observe one Record while restoring the original method after cleanup.
 * @param {Object} record
 * @returns {Object}
 */
function getRecordChangeBinding(record) {
    let binding = recordChangeBindings.get(record);

    if (!binding) {
        const
            originalNotifyChange = record.notifyChange,
            prototype            = Object.getPrototypeOf(record),
            handlers             = new Set();

        binding = {
            handlers,
            originalNotifyChange,
            wrapper(data, silent=false) {
                const result = originalNotifyChange.call(this, {
                    changedFields: [],
                    ...data
                }, silent);

                handlers.forEach(handler => handler(result));

                return result
            },
            restore() {
                if (record.notifyChange === binding.wrapper) {
                    if (prototype.notifyChange === originalNotifyChange) {
                        delete record.notifyChange
                    } else {
                        record.notifyChange = originalNotifyChange
                    }
                }
            }
        };

        recordChangeBindings.set(record, binding)
    }

    if (record.notifyChange !== binding.wrapper) {
        record.notifyChange = binding.wrapper
    }

    return binding
}

/**
 * An optional component state provider for adding bindings to configs
 * @class Neo.state.Provider
 * @extends Neo.core.Base
 * @mixes Neo.core.Observable
 */
class Provider extends Base {
    /**
     * True automatically applies the core.Observable mixin
     * @member {Boolean} observable=true
     * @static
     */
    static observable = true

    static config = {
        /**
         * @member {String} className='Neo.state.Provider'
         * @protected
         */
        className: 'Neo.state.Provider',
        /**
         * @member {String} ntype='state-provider'
         * @protected
         */
        ntype: 'state-provider',
        /**
         * @member {Neo.component.Base|null} component=null
         * @protected
         */
        component: null,
        /**
         * The core data object managed by this StateProvider.
         * This object holds the reactive state that can be accessed and modified
         * by components and formulas within the provider's hierarchy.
         * Changes to properties within this data object will trigger reactivity.
         * When new data is assigned, it will be deeply merged with existing data.
         * @member {Object|null} data_={}
         * @example
         *     data: {
         *         user: {
         *             firstName: 'John',
         *             lastName : 'Doe'
         *         },
         *         settings: {
         *             theme: 'dark'
         *         }
         *     }
         * @reactive
         */
        data_: {
            [isDescriptor]: true,
            merge         : 'deep',
            value         : {}
        },
        /**
         * Defines computed properties based on other data properties within the StateProvider hierarchy.
         * Each formula is a function that receives a `data` argument, which is a hierarchical proxy
         * allowing access to data from the current provider and all its parent providers.
         * Changes to dependencies (accessed via `data.propertyName`) will automatically re-run the formula.
         * @member {Object|null} formulas_=null
         * @example
         *     data: {
         *         a    : 1,
         *         b    : 2,
         *         total: 50
         *     }
         *     formulas: {
         *         aPlusB : (data) => data.a + data.b,
         *         aTimesB: (data) => data.a * data.b,
         *         // Accessing parent data (assuming a parent provider has a 'taxRate' property)
         *         totalWithTax: (data) => data.total * (1 + data.taxRate)
         *     }
         * @reactive
         */
        formulas_: null,
        /**
         * @member {Neo.state.Provider|null} parent_=null
         * @reactive
         */
        parent_: null,
        /**
         /**
         * A collection of Neo.data.Store instances managed by this StateProvider.
         * Stores are defined as config objects with a `module` property pointing
         * to the store class, which will then be instantiated by the framework.
         * @member {Object|null} stores_=null
         * @example
         *     stores: {
         *         myUsers: {
         *             module: Neo.data.Store,
         *             model : 'MyApp.model.User',
         *             data  : [{id: 1, name: 'John'}, {id: 2, name: 'Doe'}]
         *         },
         *         myCustomStore1: MyCustomStoreClass,
         *         myCustomStore2: {
         *             module  : MyCustomStoreClass,
         *             autoLoad: true
         *         }
         *     }
         * @reactive
         */
        stores_: null,
        /**
         * @member {String|null} windowId=null
         */
        windowId: null
    }

    /**
     * @member {Map} #bindingEffects=new Map()
     * @private
     */
    #bindingEffects = new Map()
    /**
     * @member {Object} #dataConfigs={}
     * @private
     */
    #dataConfigs = {}
    /**
     * @member {Map} #formulaEffects=new Map()
     * @private
     */
    #formulaEffects = new Map()
    /**
     * Tracks provider-owned Record field bindings by StateProvider data path.
     * @member {Map} #recordDataBindings=new Map()
     * @private
     */
    #recordDataBindings = new Map()

    /**
     * @param {Object} config
     */
    construct(config) {
        Neo.isUsingStateProviders = true;
        super.construct(config)
    }

    /**
     * @param {*} value
     * @returns {*}
     * @protected
     */
    adjustValue(value) {
        if (value instanceof Date) {
            return new Date(value.valueOf())
        }

        return value
    }

    /**
     * Triggered after the data config got changed.
     * This method initializes the internal #dataConfigs map, converting each
     * plain data property into a reactive Neo.core.Config instance.
     * @param {Object|null} value
     * @param {Object|null} oldValue
     * @protected
     */
    afterSetData(value, oldValue) {
        value && this.processDataObject(value)
    }

    /**
     * Triggered after the formulas config got changed.
     * This method sets up reactive effects for each defined formula.
     * Each formula function receives the hierarchical data proxy, allowing implicit dependency tracking.
     * @param {Object|null} value The new formulas configuration.
     * @param {Object|null} oldValue The old formulas configuration.
     * @protected
     */
    afterSetFormulas(value, oldValue) {
        const me = this;

        // Destroy old formula effects to prevent memory leaks and stale calculations.
        me.#formulaEffects.forEach(effect => effect.destroy());
        me.#formulaEffects.clear();

        if (value) {
            Object.entries(value).forEach(([formulaKey, formulaFn]) => {
                // Create a new lazy Effect. It will not run until explicitly told to.
                const effect = new Effect({
                    fn: () => {
                        const
                            hierarchicalData = me.getHierarchyData(),
                            result           = formulaFn(hierarchicalData);

                        me.setData(formulaKey, result);
                    },
                    lazy: true
                });

                me.#formulaEffects.set(formulaKey, effect)
            })
        }
    }

    /**
     * Triggered when accessing the data config
     * @param {Object} value
     * @protected
     */
    beforeGetData(value) {
        return this.getHierarchyData()
    }

    /**
     * Triggered before the stores config gets changed.
     * @summary Normalizes provider-local store configs before instantiation, including predictable same-provider
     * ids and sibling-key `sourceId` references.
     * @param {Object|null} value
     * @param {Object|null} oldValue
     * @returns {Object|null}
     * @protected
     */
    beforeSetStores(value, oldValue) {
        if (value) {
            const
                me       = this,
                storeIds = {};

            Object.entries(value).forEach(([key, storeValue]) => {
                const storeId = me.getProviderStoreId(key, storeValue);

                if (storeId) {
                    storeIds[key] = storeId
                }
            });

            Object.entries(value).forEach(([key, storeValue]) => {
                storeValue = me.normalizeProviderStoreConfig(key, storeValue, storeIds);

                // support mapping string based listeners into the stateProvider instance
                Object.entries(storeValue.listeners || {}).forEach(([listenerKey, listener]) => {
                    me.bindCallback(listener, listenerKey, me, storeValue.listeners)
                })

                value[key] = ClassSystemUtil.beforeSetInstance(storeValue)
            })
        }

        return value
    }

    /**
     * Resolves the id a provider-local store config will use after instantiation.
     * @summary Store config keys become predictable instance ids unless an explicit id or existing instance id
     * already owns the store identity.
     * @param {String} key
     * @param {Class|Object|Neo.core.Base} storeValue
     * @returns {String|null}
     * @protected
     */
    getProviderStoreId(key, storeValue) {
        const type = Neo.typeOf(storeValue);

        if (type === 'NeoInstance') {
            return storeValue.id
        }

        if (type === 'Object') {
            return storeValue.id || `${this.id}__${key}`
        }

        if (type === 'NeoClass') {
            return `${this.id}__${key}`
        }

        return null
    }

    /**
     * Normalizes a provider-local store declaration before the ClassSystem creates the instance.
     * @summary Converts class shorthand to config objects, assigns missing predictable ids, and resolves
     * sibling store-key `sourceId` values to their concrete instance ids.
     * @param {String} key
     * @param {Class|Object|Neo.core.Base} storeValue
     * @param {Object} storeIds
     * @returns {Class|Object|Neo.core.Base}
     * @protected
     */
    normalizeProviderStoreConfig(key, storeValue, storeIds) {
        let type = Neo.typeOf(storeValue);

        if (type === 'NeoInstance') {
            return storeValue
        }

        if (type === 'NeoClass') {
            storeValue = {module: storeValue};
            type       = 'Object'
        }

        if (type === 'Object') {
            const config = {...storeValue};

            if (!config.id) {
                config.id = storeIds[key]
            }

            if (Neo.isString(config.sourceId) && storeIds[config.sourceId]) {
                config.sourceId = storeIds[config.sourceId]
            }

            return config
        }

        return storeValue
    }

    /**
     * Creates a new binding for a component's config to a data property.
     * This now uses the Effect-based reactivity system.
     * @param {String} componentId
     * @param {String} configKey The component config to bind (e.g., 'text').
     * @param {String|Function} formatter The function that computes the value.
     */
    createBinding(componentId, configKey, formatter) {
        const
            me        = this,
            mapKey    = `${componentId}-${configKey}`,
            oldEffect = me.#bindingEffects.get(mapKey);

        if (oldEffect) {
            oldEffect.destroy()
        }

        const effect = new Effect(() => {
            const component = Neo.get(componentId);

            if (component && !component.isDestroyed) {
                const
                    hierarchicalData = me.getHierarchyData(),
                    newValue         = Neo.isFunction(formatter) ? formatter.call(me, hierarchicalData) : hierarchicalData[formatter];

                component._skipTwoWayPush = configKey;
                component[configKey] = newValue;
                delete component._skipTwoWayPush
            }
        });

        me.#bindingEffects.set(mapKey, effect);

        // The effect observes the component's destruction to clean itself up.
        me.observeConfig(componentId, 'isDestroying', (value) => {
            if (value) {
                effect.destroy();
                me.#bindingEffects.delete(mapKey)
            }
        });

        // The effect is returned to be managed by the component.
        return effect
    }

    /**
     * Processes a component's `bind` configuration to create reactive bindings.
     * It differentiates between store bindings and data bindings, and sets up two-way binding if specified.
     * @param {Neo.component.Base} component The component instance whose bindings are to be created.
     */
    createBindings(component) {
        let me               = this,
            hasTwoWayBinding = false;

        Object.entries(component.bind || {}).forEach(([configKey, value]) => {
            let key = value;

            // If the binding value is an object, it might contain `twoWay` or a specific `key`.
            if (Neo.isObject(value)) {
                if (value.twoWay) {
                    hasTwoWayBinding = true
                }
                key = value.key
            }

            // Determine if it's a store binding or a data binding.
            if (me.isStoreValue(key)) {
                // For store bindings, resolve the store and assign it to the component config.
                me.resolveStore(component, configKey, key.substring(7)) // remove the "stores." prefix
            } else {
                // For data bindings, create an Effect to keep the component config in sync with the data.
                me.createBinding(component.id, configKey, key, value.twoWay)
            }
        });

        // Mark the component if it has any two-way bindings, for internal tracking.
        if (hasTwoWayBinding) {
            component[twoWayBindingSymbol] = true
        }
    }

    /**
     * Destroys the state provider and cleans up all associated effects.
     */
    destroy() {
        const me = this;

        me.#clearRecordDataBindings();

        me.#formulaEffects.forEach(effect => effect.destroy());
        me.#formulaEffects.clear();

        me.#bindingEffects.forEach(effect => effect.destroy());
        me.#bindingEffects.clear();

        super.destroy()
    }

    /**
     * Convenience shortcut
     * @param {String} [ntype]
     * @returns {Neo.controller.Component|null}
     */
    getController(ntype) {
        return this.component.getController(ntype)
    }

    /**
     * Access the closest data property inside the parent chain.
     * @param {String} key
     * @returns {*} value
     */
    getData(key) {
        const ownerDetails = this.getOwnerOfDataProperty(key);

        if (ownerDetails) {
            return ownerDetails.owner.getDataConfig(ownerDetails.propertyName).get()
        }
    }

    /**
     * Retrieves the underlying core.Config instance for a given data property path.
     * @param {String} path The full path of the data property (e.g., 'user.firstname').
     * @returns {Neo.core.Config|null}
     */
    getDataConfig(path) {
        return this.#dataConfigs[path] || null
    }

    /**
     * Returns the merged, hierarchical data object as a reactive Proxy.
     * @returns {Proxy}
     */
    getHierarchyData() {
        return createHierarchicalDataProxy(this)
    }

    /**
     * Finds the state.Provider instance that owns a specific data property.
     * @param {String} path The full path of the data property.
     * @returns {{owner: Neo.state.Provider, propertyName: String}|null}
     */
    getOwnerOfDataProperty(path) {
        let me = this;

        if (me.#dataConfigs[path]) {
            return {owner: me, propertyName: path}
        }

        // Check for parent ownership
        const parent = me.getParent();
        if (parent) {
            return parent.getOwnerOfDataProperty(path)
        }

        return null
    }

    /**
     * Get the closest stateProvider inside the components parent tree
     * @returns {Neo.state.Provider|null}
     */
    getParent() {
        let me = this;

        // Access the internal value of the parent_ config directly.
        // This avoids recursive calls to the getter.
        if (me._parent) {
            return me._parent
        }

        // If no explicit parent is set, try to find it dynamically via the component.
        // Ensure this.component exists before trying to access its parent.
        if (me.component) {
            return me.component.parent?.getStateProvider() || null
        }

        // No explicit parent and no component to derive it from.
        return null
    }

    /**
     * Access the closest store inside the VM parent chain.
     * @param {String} key
     * @param {Neo.state.Provider} originStateProvider=this for internal usage only
     * @returns {Neo.data.Store}
     */
    getStore(key, originStateProvider=this) {
        let me       = this,
            {stores} = me,
            parentStateProvider;

        if (stores?.hasOwnProperty(key)) {
            return stores[key]
        }

        parentStateProvider = me.getParent();

        if (!parentStateProvider) {
            console.error(`store '${key}' not found inside this stateProvider or parents.`, originStateProvider)
        }

        return parentStateProvider.getStore(key, originStateProvider)
    }

    /**
     * Checks if any data property in the hierarchy starts with the given path.
     * This is used by the HierarchicalDataProxy to determine if it should return a nested proxy.
     * @param {String} path The path to check (e.g., 'user').
     * @returns {Boolean}
     */
    hasNestedDataStartingWith(path) {
        const pathWithDot = `${path}.`;

        if (Object.keys(this.#dataConfigs).some(key => key.startsWith(pathWithDot))) {
            return true
        }

        return this.getParent()?.hasNestedDataStartingWith(path) || false
    }

    /**
     * Returns the top-level data keys for a given path within this provider's data.
     * @param {String} path The path to get keys for (e.g., 'user.address').
     * @returns {String[]}
     */
    getTopLevelDataKeys(path) {
        const
            keys       = new Set(),
            pathPrefix = path ? `${path}.` : '';

        for (const fullPath in this.#dataConfigs) {
            if (fullPath.startsWith(pathPrefix)) {
                const
                    relativePath = fullPath.substring(pathPrefix.length),
                    topLevelKey  = relativePath.split('.')[0];

                if (topLevelKey) {
                    keys.add(topLevelKey)
                }
            }
        }

        return Array.from(keys)
    }

    /**
     * This is the core method for setting data, providing a single entry point for all data modifications.
     * It handles multiple scenarios:
     * 1.  **Object-based updates:** If `key` is an object, it recursively calls itself for each key-value pair.
     * 2.  **Data Records:** If `value` is a `Neo.data.Record`, it is treated as an atomic value and set directly.
     * 3.  **Bubbling Reactivity:** For a given key (e.g., 'user.name'), it sets the leaf value and then "bubbles up"
     *     the change, creating new parent objects (e.g., 'user') to ensure that effects depending on any part
     *     of the path are triggered.
     *
     * All updates are batched by the public `setData` methods to ensure effects run only once.
     * Use `setData()` or `setDataAtSameLevel()` instead of calling this method directly.
     *
     * @param {Object|String} key The property to set, or an object of key-value pairs.
     * @param {*} value The new value.
     * @param {Neo.state.Provider} [originStateProvider] The provider to start the search from for hierarchical updates.
     * @protected
     */
    internalSetData(key, value, originStateProvider) {
        const me = this;

        if (Neo.isObject(key)) {
            Object.entries(key).forEach(([dataKey, dataValue]) => {
                me.internalSetData(dataKey, dataValue, originStateProvider)
            });
            return
        }

        // Now 'key' is a string path.
        // If 'value' is a plain object, we need to drill down further.
        // If the value is a Neo.data.Record, treat it as an atomic value => it will not enter this block.
        if (Neo.typeOf(value) === 'Object') {
            Object.entries(value).forEach(([nestedKey, nestedValue]) => {
                const fullPath = `${key}.${nestedKey}`;
                me.internalSetData(fullPath, nestedValue, originStateProvider);
            });
            return // We've delegated the setting to deeper paths.
        }

        const
            ownerDetails   = me.getOwnerOfDataProperty(key),
            targetProvider = ownerDetails ? ownerDetails.owner : (originStateProvider || me);

        me.#setConfigValue(targetProvider, key, value, null);

        // This is the "reactivity bubbling" logic. When a leaf property like 'user.name' changes,
        // we must also trigger effects that depend on the parent object 'user'. We do this by
        // creating a new object reference for each parent in the path. The spread syntax
        // `{ ...oldParentValue, [leafKey]: latestValue }` is key, as it creates a new
        // object, which the reactivity system detects as a change.
        let path        = key,
            latestValue = value;

        while (path.includes('.')) {
            const leafKey = path.split('.').pop();
            path = path.substring(0, path.lastIndexOf('.'));

            const parentConfig = targetProvider.getDataConfig(path);

            if (parentConfig) {
                const oldParentValue = parentConfig.get();
                if (Neo.isObject(oldParentValue)) {
                    const newParentValue = { ...oldParentValue, [leafKey]: latestValue };
                    parentConfig.set(newParentValue);
                    latestValue = newParentValue;
                } else {
                    break // Stop if parent is not an object
                }
            } else {
                // If the parent config doesn't exist, we need to create it to support bubbling.
                // This is crucial for creating new nested data structures at runtime.
                const newParentValue = {[leafKey]: latestValue};
                me.#setConfigValue(targetProvider, path, newParentValue);
                latestValue = newParentValue
            }
        }
    }

    /**
     * Internal convenience method to check if a binding value is supposed to match a store
     * @param {String} value
     * @returns {Boolean}
     */
    isStoreValue(value) {
        return Neo.isString(value) && value.startsWith('stores.')
    }

    /**
     * Gets called after all constructors & configs are applied.
     * @protected
     */
    onConstructed() {
        super.onConstructed();

        // After the provider is fully constructed and initial data is set,
        // run the formula effects for the first time to compute their initial values.
        this.#formulaEffects.forEach(effect => effect.run())
    }

    /**
     * @param {String} key
     * @param {*} value
     * @param {*} oldValue
     */
    onDataPropertyChange(key, value, oldValue) {
        // Can be overridden by subclasses
    }

    /**
     * Recursively processes a data object, creating or updating Neo.core.Config instances
     * for each property and storing them in the #dataConfigs map.
     * @param {Object} obj The data object to process.
     * @param {String} [path=''] The current path prefix for nested objects.
     * @protected
     */
    processDataObject(obj, path = '') {
        let me = this;

        Object.entries(obj).forEach(([key, value]) => {
            const fullPath = path ? `${path}.${key}` : key;

            value = me.adjustValue(value);

            // Ensure a Config instance exists for the current fullPath
            if (me.#dataConfigs[fullPath]) {
                me.#dataConfigs[fullPath].set(value)
            } else {
                me.#dataConfigs[fullPath] = new Config(value)
            }

            me.#syncRecordDataValue(fullPath, value);

            // If the value is a plain object, recursively process its properties
            if (Neo.typeOf(value) === 'Object') {
                me.processDataObject(value, fullPath)
            }
        });
    }

    /**
     * @param {Neo.component.Base} component
     * @param {String}             configName
     * @param {String}             storeName
     */
    resolveStore(component, configName, storeName) {
        let store = this.getStore(storeName);

        if (component[configName] !== store) {
            component[configName] = store
        }
    }

    /**
     * Helper function to set a config value and trigger reactivity.
     * This method creates a new Config instance if one doesn't exist for the given path,
     * or updates an existing one. It also triggers binding effects and calls onDataPropertyChange.
     * @param {Neo.state.Provider} provider The StateProvider instance owning the config.
     * @param {String}             path     The full path of the data property (e.g., 'user.firstname').
     * @param {*}                  newValue The new value to set.
     * @param {*}                 [oldVal]  The old value (optional, used for initial setup).
     * @private
     */
    #setConfigValue(provider, path, newValue, oldVal) {
        newValue = provider.adjustValue(newValue);

        let currentConfig = provider.getDataConfig(path),
            hasChange     = true,
            oldValue      = oldVal;

        if (currentConfig) {
            oldValue  = currentConfig.get();
            hasChange = currentConfig.set(newValue)
        } else {
            currentConfig = new Config(newValue);
            provider.#dataConfigs[path] = currentConfig;
            // Trigger all binding effects to re-evaluate their dependencies
            provider.#bindingEffects.forEach(effect => effect.run())
        }

        if (hasChange) {
            provider.#syncRecordDataValue(path, newValue);

            // Notify subscribers of the data property change.
            provider.onDataPropertyChange(path, newValue, oldValue)
        }
    }

    /**
     * Releases all record-field subscriptions owned by this provider.
     * @summary Prevents destroyed providers from retaining callbacks on external record instances.
     * @private
     */
    #clearRecordDataBindings() {
        Array.from(this.#recordDataBindings.keys()).forEach(path => this.#unbindRecordData(path))
    }

    /**
     * Handles a field-change payload emitted by a bound Record.
     * @summary Translates record-level changed fields into StateProvider data-path config updates.
     * @param {String} path
     * @param {Object} payload
     * @private
     */
    #onRecordDataChange(path, payload) {
        const {changedFields=[], silent} = payload;

        if (silent || this.isDestroying || !changedFields.length) {
            return
        }

        EffectManager.pause();
        try {
            changedFields.forEach(({name, oldValue, value}) => {
                const fieldPath = `${path}.${name}`;

                this.#setConfigValue(this, fieldPath, value, oldValue);
                this.#syncRecordParentPath(path, fieldPath, value)
            })
        } finally {
            EffectManager.resume()
        }
    }

    /**
     * Registers this provider for field changes on a Record stored in data.
     * @summary Uses a removable wrapper around Record#notifyChange instead of an append-only sequence.
     * @param {String} path
     * @param {Object} record
     * @private
     */
    #bindRecordData(path, record) {
        const current = this.#recordDataBindings.get(path);

        if (current?.record === record) {
            this.#syncRecordDataFields(path, record);
            return
        }

        this.#unbindRecordData(path);

        const
            binding = getRecordChangeBinding(record),
            handler = payload => this.#onRecordDataChange(path, payload);

        binding.handlers.add(handler);

        this.#recordDataBindings.set(path, {binding, handler, record});
        this.#syncRecordDataFields(path, record)
    }

    /**
     * Mirrors a Record's current field values into StateProvider data-path configs.
     * @summary Enables bindings like `currentUser.firstName` while keeping `currentUser` as the record value.
     * @param {String} path
     * @param {Object} record
     * @private
     */
    #syncRecordDataFields(path, record) {
        const data = record.toJSON?.();

        if (Neo.typeOf(data) !== 'Object') {
            return
        }

        Object.entries(data).forEach(([key, value]) => {
            const fieldPath = `${path}.${key}`;

            this.#setConfigValue(this, fieldPath, value);

            if (Neo.typeOf(value) === 'Object') {
                this.processDataObject(value, fieldPath)
            }
        })
    }

    /**
     * Bubbles a nested record-field change up to record child-object configs.
     * @summary Updates `recordPath.address` for a `recordPath.address.city` change without replacing the record.
     * @param {String} recordPath
     * @param {String} fieldPath
     * @param {*} value
     * @private
     */
    #syncRecordParentPath(recordPath, fieldPath, value) {
        let
            latestValue = value,
            path        = fieldPath;

        while (path.includes('.')) {
            const leafKey = path.split('.').pop();
            path = path.substring(0, path.lastIndexOf('.'));

            if (path === recordPath) {
                break
            }

            const
                oldParentValue = this.getDataConfig(path)?.get(),
                newParentValue = Neo.typeOf(oldParentValue) === 'Object'
                    ? {...oldParentValue, [leafKey]: latestValue}
                    : {[leafKey]: latestValue};

            this.#setConfigValue(this, path, newParentValue, oldParentValue);
            latestValue = newParentValue
        }
    }

    /**
     * Registers or clears Record field integration for a StateProvider data value.
     * @summary Record values stay atomic at their own path while their fields get child configs.
     * @param {String} path
     * @param {*} value
     * @private
     */
    #syncRecordDataValue(path, value) {
        if (value?.isRecord) {
            this.#bindRecordData(path, value)
        } else {
            this.#unbindRecordData(path)
        }
    }

    /**
     * Removes a previously registered Record field subscription.
     * @summary Detaches stale record/path callbacks on replacement and provider destroy.
     * @param {String} path
     * @private
     */
    #unbindRecordData(path) {
        const current = this.#recordDataBindings.get(path);

        if (!current) {
            return
        }

        current.binding.handlers.delete(current.handler);

        if (!current.binding.handlers.size) {
            current.binding.restore()
        }

        this.#recordDataBindings.delete(path)
    }

    /**
     * The method will assign all values to the closest stateProvider where it finds an existing key.
     * In case no match is found inside the parent chain, a new data property will get generated.
     *
     * All updates within a single call are batched to ensure that reactive effects (bindings and formulas)
     * are run only once.
     *
     * @param {Object|String} key
     * @param {*}             value
     */
    setData(key, value) {
        EffectManager.pause();
        try {
            this.internalSetData(key, value, this)
        } finally {
            EffectManager.resume()
        }
    }

    /**
     * Use this method instead of setData() in case you want to enforce
     * setting all keys on this instance instead of looking for matches inside parent stateProviders.
     *
     * All updates within a single call are batched to ensure that reactive effects (bindings and formulas)
     * are run only once.
     *
     * @param {Object|String} key
     * @param {*}             value
     */
    setDataAtSameLevel(key, value) {
        EffectManager.pause();
        try {
            this.internalSetData(key, value)
        } finally {
            EffectManager.resume()
        }
    }

    /**
     * Serializes the instance into a JSON-compatible object for the Neural Link.
     * @returns {Object}
     */
    toJSON() {
        const
            me     = this,
            stores = {};

        if (me.stores) {
            Object.entries(me.stores).forEach(([key, value]) => {
                stores[key] = value.toJSON()
            })
        }

        return {
            ...super.toJSON(),
            component: me.component?.id,
            data     : me.data,
            parent   : me.parent?.id,
            stores,
            windowId : me.windowId
        }
    }
}

export default Neo.setupClass(Provider);
