/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
package org.apache.openjpa.kernel;

import java.io.Serializable;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;

import org.apache.openjpa.conf.OpenJPAConfiguration;
import org.apache.openjpa.lib.rop.EagerResultList;
import org.apache.openjpa.lib.rop.ListResultObjectProvider;
import org.apache.openjpa.lib.rop.ResultList;
import org.apache.openjpa.lib.rop.ResultObjectProvider;
import org.apache.openjpa.lib.rop.SimpleResultList;
import org.apache.openjpa.lib.rop.WindowResultList;
import org.apache.openjpa.lib.util.Localizer;
import org.apache.openjpa.lib.util.StringUtil;
import org.apache.openjpa.meta.ClassMetaData;
import org.apache.openjpa.meta.FetchGroup;
import org.apache.openjpa.meta.FieldMetaData;
import org.apache.openjpa.meta.JavaTypes;
import org.apache.openjpa.util.ImplHelper;
import org.apache.openjpa.util.InternalException;
import org.apache.openjpa.util.NoTransactionException;
import org.apache.openjpa.util.UserException;

/**
 * Allows configuration and optimization of how objects are loaded from
 * the data store.
 *
 * @since 0.3.0
 * @author Abe White
 * @author Pinaki Poddar
 */
public class FetchConfigurationImpl
    implements FetchConfiguration, Cloneable {

    private static final long serialVersionUID = 1L;
    private static final Localizer _loc = Localizer.forPackage(FetchConfigurationImpl.class);
    private static Map<String, Method> _hintSetters = new HashMap<>();

    /**
     * Registers hint keys that have a corresponding setter method.
     * The hint keys are registered in <code>openjpa.FetchPlan</code> and <code>openjpa</code> as prefix.
     * Also some keys are registered in <code>jakarta.persistence</code> namespace.
     */
    static {
            String[] prefixes = {"openjpa.FetchPlan", "openjpa"};
            Class<?> target = FetchConfiguration.class;
            populateHintSetter(target, "ExtendedPathLookup", boolean.class, prefixes);
            populateHintSetter(target, "FetchBatchSize", int.class, prefixes);
            populateHintSetter(target, "FlushBeforeQueries", int.class, prefixes);
            populateHintSetter(target, "LockScope", int.class, prefixes);
            populateHintSetter(target, "LockTimeout", int.class, prefixes);
            populateHintSetter(target, "setLockTimeout", "timeout", int.class, "jakarta.persistence.lock");
            populateHintSetter(target, "MaxFetchDepth", int.class, prefixes);
            populateHintSetter(target, "QueryTimeout", int.class, prefixes);
            populateHintSetter(target, "setQueryTimeout", "timeout", int.class, "jakarta.persistence.query");
            populateHintSetter(target, "ReadLockLevel", int.class, prefixes);
            populateHintSetter(target, "setReadLockLevel", "ReadLockMode", int.class, prefixes);
            populateHintSetter(target, "WriteLockLevel", int.class, prefixes);
            populateHintSetter(target, "setWriteLockLevel", "WriteLockMode", int.class, prefixes);
    }

    /**
     * Populate static registry of hints.
     *
     * @param target The name of the target class that will receive this hint.
     * @param hint the simple name of the hint without a prefix.
     * @param type the value argument type of the target setter method.
     * @param prefixes the prefixes will be added to the simple hint name.
     */
    protected static void populateHintSetter(Class<?> target, String hint, Class<?> type, String...prefixes) {
        populateHintSetter(target, "set" + hint, hint, type, prefixes);
    }

    /**
     * Populate static registry of hints.
     *
     * @param target The name of the target class that will receive this hint.
     * @param method The name of the method in the target class that will receive this hint.
     * @param hint the simple name of the hint without a prefix.
     * @param type the value argument type of the target setter method.
     * @param prefixes the prefixes will be added to the simple hint name.
     */
    protected static void populateHintSetter(Class<?> target, String method, String hint, Class<?> type,
            String...prefixes) {
        try {
            Method setter = target.getMethod(method, type);
            for (String prefix : prefixes) {
                _hintSetters.put(prefix + "." + hint, setter);
            }
        } catch (Exception e) {
            // should not reach
            throw new InternalException("setter for " + hint + " with argument " + type + " does not exist");
        }
    }

    /**
     * Configurable state shared throughout a traversal chain.
     */
    protected static class ConfigurationState
        implements Serializable
    {
        
        private static final long serialVersionUID = 1L;
        public transient StoreContext ctx = null;
        public int fetchBatchSize = 0;
        public int maxFetchDepth = 1;
        public boolean queryCache = true;
        public int flushQuery = 0;
        public int lockTimeout = -1;
        public int queryTimeout = -1;
        public int lockScope = LOCKSCOPE_NORMAL;
        public int readLockLevel = LOCK_NONE;
        public int writeLockLevel = LOCK_NONE;
        public Set<String> fetchGroups = null;
        public Set<String> fields = null;
        public Set<Class<?>> rootClasses;
        public Set<Object> rootInstances;
        public Map<String,Object> hints = null;
        public boolean fetchGroupContainsDefault = false;
        public boolean fetchGroupContainsAll = false;
        public boolean fetchGroupIsPUDefault = false;
        public boolean extendedPathLookup = false;
        public DataCacheRetrieveMode cacheRetrieveMode = DataCacheRetrieveMode.USE;
        public DataCacheStoreMode cacheStoreMode = DataCacheStoreMode.USE;
        public boolean cacheNonDefaultFetchPlanQueries = false;
    }

    private final ConfigurationState _state;
    private FetchConfigurationImpl _parent;
    private String _fromField;
    private Class<?> _fromType;
    private String _directRelationOwner;
    private boolean _load = true;
    private int _availableRecursion;
    private int _availableDepth;

    public FetchConfigurationImpl() {
        this(null);
    }

    protected FetchConfigurationImpl(ConfigurationState state) {
        _state = (state == null) ? new ConfigurationState() : state;
        _availableDepth = _state.maxFetchDepth;
    }

    @Override
    public StoreContext getContext() {
        return _state.ctx;
    }

    @Override
    public void setContext(StoreContext ctx) {
        // can't reset non-null context to another context
        if (ctx != null && _state.ctx != null && ctx != _state.ctx)
            throw new InternalException();
        _state.ctx = ctx;
        if (ctx == null)
            return;

        // initialize to conf info
        OpenJPAConfiguration conf = ctx.getConfiguration();
        setFetchBatchSize(conf.getFetchBatchSize());
        setFlushBeforeQueries(conf.getFlushBeforeQueriesConstant());
        setLockTimeout(conf.getLockTimeout());
        setQueryTimeout(conf.getQueryTimeout());

        String[] fetchGroupList = conf.getFetchGroupsList();
        clearFetchGroups((fetchGroupList == null || fetchGroupList.length == 0));

        addFetchGroups(Arrays.asList(fetchGroupList));
        setMaxFetchDepth(conf.getMaxFetchDepth());

        _state.cacheNonDefaultFetchPlanQueries = conf.getCompatibilityInstance().getCacheNonDefaultFetchPlanQueries();
    }

    /**
     * Clone this instance.
     */
    @Override
    public Object clone() {
        FetchConfigurationImpl clone = newInstance(null);
        clone._state.ctx = _state.ctx;
        clone._state.cacheNonDefaultFetchPlanQueries = _state.cacheNonDefaultFetchPlanQueries;
        clone._parent = _parent;
        clone._fromField = _fromField;
        clone._fromType = _fromType;
        clone._directRelationOwner = _directRelationOwner;
        clone._load = _load;
        clone._availableRecursion = _availableRecursion;
        clone._availableDepth = _availableDepth;
        clone.copy(this);
        return clone;
    }

    /**
     * Return a new hollow instance.
     */
    protected FetchConfigurationImpl newInstance(ConfigurationState state) {
        return new FetchConfigurationImpl(state);
    }

    @Override
    public void copy(FetchConfiguration fetch) {
        setFetchBatchSize(fetch.getFetchBatchSize());
        setMaxFetchDepth(fetch.getMaxFetchDepth());
        setQueryCacheEnabled(fetch.getQueryCacheEnabled());
        setFlushBeforeQueries(fetch.getFlushBeforeQueries());
        setExtendedPathLookup(fetch.getExtendedPathLookup());
        setLockTimeout(fetch.getLockTimeout());
        setQueryTimeout(fetch.getQueryTimeout());
        setLockScope(fetch.getLockScope());
        clearFetchGroups(false);
        addFetchGroups(fetch.getFetchGroups());
        clearFields();
        copyHints(fetch);
        setCacheRetrieveMode(fetch.getCacheRetrieveMode());
        setCacheStoreMode(fetch.getCacheStoreMode());
        addFields(fetch.getFields());

        // don't use setters because require active transaction
        _state.readLockLevel = fetch.getReadLockLevel();
        _state.writeLockLevel = fetch.getWriteLockLevel();
    }


    void copyHints(FetchConfiguration fetch) {
        if (!(fetch instanceof FetchConfigurationImpl))
            return;
        FetchConfigurationImpl from = (FetchConfigurationImpl)fetch;
        if (from._state == null || from._state.hints == null)
            return;
        if (this._state == null)
            return;
        if (this._state.hints == null)
            this._state.hints = new HashMap<>();
        this._state.hints.putAll(from._state.hints);
    }

    @Override
    public int getFetchBatchSize() {
        return _state.fetchBatchSize;
    }

    @Override
    public FetchConfiguration setFetchBatchSize(int fetchBatchSize) {
        if (fetchBatchSize == DEFAULT && _state.ctx != null)
            fetchBatchSize = _state.ctx.getConfiguration().getFetchBatchSize();
        if (fetchBatchSize != DEFAULT)
            _state.fetchBatchSize = fetchBatchSize;
        return this;
    }

    @Override
    public int getMaxFetchDepth() {
        return _state.maxFetchDepth;
    }

    @Override
    public FetchConfiguration setMaxFetchDepth(int depth) {
        if (depth == DEFAULT && _state.ctx != null)
            depth = _state.ctx.getConfiguration().getMaxFetchDepth();
        if (depth != DEFAULT)
        {
            _state.maxFetchDepth = depth;
            if (_parent == null)
                _availableDepth = depth;
        }
        return this;
    }

    @Override
    public boolean getQueryCacheEnabled() {
        return _state.queryCache;
    }

    @Override
    public FetchConfiguration setQueryCacheEnabled(boolean cache) {
        _state.queryCache = cache;
        return this;
    }

    @Override
    public int getFlushBeforeQueries() {
        return _state.flushQuery;
    }

    @Override
    public boolean getExtendedPathLookup() {
        return _state.extendedPathLookup;
    }

    @Override
    public FetchConfiguration setExtendedPathLookup(boolean flag) {
        _state.extendedPathLookup = flag;
        return this;
    }

    @Override
    public FetchConfiguration setFlushBeforeQueries(int flush) {
        if (flush != DEFAULT
            && flush != QueryFlushModes.FLUSH_TRUE
            && flush != QueryFlushModes.FLUSH_FALSE
            && flush != QueryFlushModes.FLUSH_WITH_CONNECTION)
            throw new IllegalArgumentException(_loc.get(
                "bad-flush-before-queries", flush).getMessage());

        if (flush == DEFAULT && _state.ctx != null)
            _state.flushQuery = _state.ctx.getConfiguration().
                getFlushBeforeQueriesConstant();
        else if (flush != DEFAULT)
            _state.flushQuery = flush;
        return this;
    }

    @Override
    public Set<String> getFetchGroups() {
        if (_state.fetchGroups == null) return Collections.emptySet();
        return _state.fetchGroups;
    }

    @Override
    public boolean hasFetchGroup(String group) {
        return _state.fetchGroups != null
            && (_state.fetchGroups.contains(group)
            || _state.fetchGroups.contains(FetchGroup.NAME_ALL));
    }

     public boolean hasFetchGroupDefault() {
         // Fetch group All includes fetch group Default by definition
         return _state.fetchGroupContainsDefault ||
             _state.fetchGroupContainsAll;
     }

     public boolean hasFetchGroupAll() {
         return _state.fetchGroupContainsAll;
     }

    @Override
    public FetchConfiguration addFetchGroup(String name) {
        return addFetchGroup(name, true);
    }

    private FetchConfiguration addFetchGroup(String name, boolean recomputeIsDefault) {
        if (StringUtil.isEmpty(name))
            throw new UserException(_loc.get("null-fg"));

        lock();
        try {
            if (_state.fetchGroups == null)
                _state.fetchGroups = new HashSet<>();
            _state.fetchGroups.add(name);

            if (FetchGroup.NAME_ALL.equals(name))
                _state.fetchGroupContainsAll = true;
            else if (FetchGroup.NAME_DEFAULT.equals(name))
                _state.fetchGroupContainsDefault = true;
        } finally {
            if (recomputeIsDefault) {
                verifyDefaultPUFetchGroups();
            }
            unlock();
        }
        return this;
    }

    @Override
    public FetchConfiguration addFetchGroups(Collection<String> groups) {
        if (groups == null || groups.isEmpty())
            return this;
        for (String group : groups)
            addFetchGroup(group, false);

        verifyDefaultPUFetchGroups();
        return this;
    }

    @Override
    public FetchConfiguration removeFetchGroup(String group) {
        return removeFetchGroup(group, true);
    }

    private FetchConfiguration removeFetchGroup(String group, boolean recomputeIsDefault) {
        lock();
        try {
            if (_state.fetchGroups != null) {
                _state.fetchGroups.remove(group);
                if (FetchGroup.NAME_ALL.equals(group))
                    _state.fetchGroupContainsAll = false;
                else if (FetchGroup.NAME_DEFAULT.equals(group))
                    _state.fetchGroupContainsDefault = false;
            }
        } finally {
            if (recomputeIsDefault) {
                verifyDefaultPUFetchGroups();
            }
            unlock();
        }
        return this;
    }

    @Override
    public FetchConfiguration removeFetchGroups(Collection<String> groups) {
        lock();
        try {
            if (_state.fetchGroups != null && groups != null)
                for (String group : groups)
                    removeFetchGroup(group, false);
        } finally {
            verifyDefaultPUFetchGroups();
            unlock();
        }
        return this;
    }

    @Override
    public FetchConfiguration clearFetchGroups() {
        return clearFetchGroups(true);
    }

    private FetchConfiguration clearFetchGroups(boolean restoresDefault) {
        lock();
        try {
            if (_state.fetchGroups != null) {
                _state.fetchGroups.clear();
            } else {
                _state.fetchGroups = new HashSet<>();
            }

            _state.fetchGroupContainsAll = false;

            if (restoresDefault) {
                _state.fetchGroupContainsDefault = true;
                _state.fetchGroups.add(FetchGroup.NAME_DEFAULT); // OPENJPA-2413
            }
        } finally {
            verifyDefaultPUFetchGroups();
            unlock();
        }
        return this;
    }

    @Override
    public FetchConfiguration resetFetchGroups() {
        String[] fetchGroupList = _state.ctx.getConfiguration().getFetchGroupsList();
        clearFetchGroups((fetchGroupList == null || fetchGroupList.length == 0));

        if (_state.ctx != null)
            addFetchGroups(Arrays.asList(fetchGroupList));

        verifyDefaultPUFetchGroups();

        return this;
    }

    /**
     * Determine if the current selection of FetchGroups is equivalent to the Configuration's default FetchGroups
     */
    private void verifyDefaultPUFetchGroups() {
        _state.fetchGroupIsPUDefault = false;

        if (_state.fields != null && !_state.fields.isEmpty()) {
            return;
        }

        if (_state.fetchGroups != null && _state.ctx != null) {
            List<String> defaultPUFetchGroups = Arrays.asList(_state.ctx.getConfiguration().getFetchGroupsList());
            if (_state.fetchGroups.size() != defaultPUFetchGroups.size()) {
                return;
            }

            for (String fetchGroupName : defaultPUFetchGroups) {
                if (!_state.fetchGroups.contains(fetchGroupName)) {
                    return;
                }
            }

            _state.fetchGroupIsPUDefault = true;
        }
    }

    @Override
    public boolean isDefaultPUFetchGroupConfigurationOnly() {
        return _state.fetchGroupIsPUDefault;
    }

    @Override
    public boolean isFetchConfigurationSQLCacheAdmissible() {
        if (_state == null || _state.cacheNonDefaultFetchPlanQueries) {
            return false;
        } else {
            // Only pctx-default matching FetchConfiguration generated SQL is cache permissible
            return _state.fetchGroupIsPUDefault;
        }
    }

    @Override
    public Set<String> getFields() {
        if (_state.fields == null) return Collections.emptySet();
        return _state.fields;
    }

    @Override
    public boolean hasField(String field) {
        return _state.fields != null && _state.fields.contains(field);
    }

    @Override
    public FetchConfiguration addField(String field) {
        if (StringUtil.isEmpty(field))
            throw new UserException(_loc.get("null-field"));

        lock();
        try {
            if (_state.fields == null)
                _state.fields = new HashSet<>();
            _state.fields.add(field);
            _state.fetchGroupIsPUDefault = false;
        } finally {
            unlock();
        }
        return this;
    }

    @Override
    public FetchConfiguration addFields(Collection<String> fields) {
        if (fields == null || fields.isEmpty())
            return this;

        lock();
        try {
            if (_state.fields == null)
                _state.fields = new HashSet<>();
            _state.fields.addAll(fields);
        } finally {
            verifyDefaultPUFetchGroups();
            unlock();
        }
        return this;
    }

    @Override
    public FetchConfiguration removeField(String field) {
        lock();
        try {
            if (_state.fields != null) {
                _state.fields.remove(field);

                if (_state.fields.size() == 0) {
                    verifyDefaultPUFetchGroups();
                }
            }
        } finally {
            unlock();
        }
        return this;
    }

    @Override
    public FetchConfiguration removeFields(Collection<String> fields) {
        lock();
        try {
            if (_state.fields != null)
                _state.fields.removeAll(fields);
        } finally {
            unlock();
        }
        return this;
    }

    @Override
    public FetchConfiguration clearFields() {
        lock();
        try {
            if (_state.fields != null)
                _state.fields.clear();
        } finally {
            verifyDefaultPUFetchGroups();
            unlock();
        }
        return this;
    }

    @Override
    public DataCacheRetrieveMode getCacheRetrieveMode() {
        return _state.cacheRetrieveMode;
    }

    @Override
    public DataCacheStoreMode getCacheStoreMode() {
        return _state.cacheStoreMode;
    }

    @Override
    public void setCacheRetrieveMode(DataCacheRetrieveMode mode) {
        _state.cacheRetrieveMode = mode;
    }

    @Override
    public void setCacheStoreMode(DataCacheStoreMode mode) {
        _state.cacheStoreMode = mode;
    }

    @Override
    public int getLockTimeout() {
        return _state.lockTimeout;
    }

    @Override
    public FetchConfiguration setLockTimeout(int timeout) {
        if (timeout == DEFAULT && _state.ctx != null)
            _state.lockTimeout = _state.ctx.getConfiguration().getLockTimeout();
        else if (timeout != DEFAULT) {
            if (timeout < -1) {
                throw new IllegalArgumentException(_loc.get("invalid-timeout",
                    timeout).getMessage());
            } else {
                _state.lockTimeout = timeout;
            }
        }
        return this;
    }

    @Override
    public int getQueryTimeout() {
        return _state.queryTimeout;
    }

    @Override
    public FetchConfiguration setQueryTimeout(int timeout) {
        if (timeout == DEFAULT && _state.ctx != null)
            _state.queryTimeout = _state.ctx.getConfiguration().
                getQueryTimeout();
        else if (timeout != DEFAULT) {
            if (timeout < -1) {
                throw new IllegalArgumentException(_loc.get("invalid-timeout",
                    timeout).getMessage());
            } else {
                _state.queryTimeout = timeout;
            }
        }
        return this;
    }

    @Override
    public int getLockScope() {
        return _state.lockScope;
    }

    @Override
    public FetchConfiguration setLockScope(int scope) {
        if (scope != DEFAULT
                && scope != LockScopes.LOCKSCOPE_NORMAL
                && scope != LockScopes.LOCKSCOPE_EXTENDED)
                throw new IllegalArgumentException(_loc.get(
                    "bad-lock-scope", scope).getMessage());
        if (scope == DEFAULT )
            _state.lockScope = LOCKSCOPE_NORMAL;
        else
            _state.lockScope = scope;
        return this;
    }

    @Override
    public int getReadLockLevel() {
        return _state.readLockLevel;
    }

    @Override
    public FetchConfiguration setReadLockLevel(int level) {
        if (_state.ctx == null)
            return this;

        if (level != DEFAULT
            && level != LockLevels.LOCK_NONE
            && level != LockLevels.LOCK_READ
            && level != MixedLockLevels.LOCK_OPTIMISTIC
            && level != LockLevels.LOCK_WRITE
            && level != MixedLockLevels.LOCK_OPTIMISTIC_FORCE_INCREMENT
            && level != MixedLockLevels.LOCK_PESSIMISTIC_READ
            && level != MixedLockLevels.LOCK_PESSIMISTIC_WRITE
            && level != MixedLockLevels.LOCK_PESSIMISTIC_FORCE_INCREMENT)
            throw new IllegalArgumentException(_loc.get(
                "bad-lock-level", level).getMessage());

        lock();
        try {
            if (level != LockLevels.LOCK_NONE)
                assertActiveTransaction();
            if (level == DEFAULT)
                _state.readLockLevel = _state.ctx.getConfiguration().
                    getReadLockLevelConstant();
            else
                _state.readLockLevel = level;
        } finally {
            unlock();
        }
        return this;
    }

    @Override
    public int getWriteLockLevel() {
        return _state.writeLockLevel;
    }

    @Override
    public FetchConfiguration setWriteLockLevel(int level) {
        if (_state.ctx == null)
            return this;

        if (level != DEFAULT
            && level != LockLevels.LOCK_NONE
            && level != LockLevels.LOCK_READ
            && level != MixedLockLevels.LOCK_OPTIMISTIC
            && level != LockLevels.LOCK_WRITE
            && level != MixedLockLevels.LOCK_OPTIMISTIC_FORCE_INCREMENT
            && level != MixedLockLevels.LOCK_PESSIMISTIC_READ
            && level != MixedLockLevels.LOCK_PESSIMISTIC_WRITE
            && level != MixedLockLevels.LOCK_PESSIMISTIC_FORCE_INCREMENT)
            throw new IllegalArgumentException(_loc.get(
                "bad-lock-level", level).getMessage());

        lock();
        try {
            assertActiveTransaction();
            if (level == DEFAULT)
                _state.writeLockLevel = _state.ctx.getConfiguration().
                    getWriteLockLevelConstant();
            else
                _state.writeLockLevel = level;
        } finally {
            unlock();
        }
        return this;
    }

    @Override
    public ResultList<?> newResultList(ResultObjectProvider rop) {
        if (rop instanceof ListResultObjectProvider)
            return new SimpleResultList(rop);
        if (_state.fetchBatchSize < 0)
            return new EagerResultList(rop);
        if (rop.supportsRandomAccess())
            return new SimpleResultList(rop);
        return new WindowResultList(rop);
    }

    /**
     * Throw an exception if no transaction is active.
     */
    private void assertActiveTransaction() {
        if (!isActiveTransaction())
            throw new NoTransactionException(_loc.get("not-active"));
    }

    private boolean isActiveTransaction() {
        return (_state.ctx != null && _state.ctx.isActive());
    }

    /**
     * Gets the current hints set on this receiver.
     * The values designate the actual value specified by the caller and not the values
     * that may have been actually set on the state variables of this receiver.
     *
     */
    @Override
    public Map<String,Object> getHints() {
        if (_state.hints == null)
            return Collections.emptyMap();
        return Collections.unmodifiableMap(_state.hints);
    }

    /**
     * Affirms if the given key is set as a hint.
     */
    @Override
    public boolean isHintSet(String key) {
        return _state.hints != null && _state.hints.containsKey(key);
    }

    /**
     * Removes the given keys and their hint value.
     */
    public void removeHint(String...keys) {
        if (keys == null || _state.hints == null )
            return;
        for (String key : keys) {
            _state.hints.remove(key);
        }
    }

    public Collection<String> getSupportedHints() {
        return _hintSetters.keySet();
    }

    /**
     * Same as <code>setHint(key, value, value)</code>.
     *
     * @see #setHint(String, Object, Object)
     */
    @Override
    public void setHint(String key, Object value) {
        setHint(key, value, value);
    }

    /**
     * Sets the hint to the given value.
     * If the key corresponds to a known key, then that value is set via the setter method.
     * Otherwise it is put into opaque hints map.
     * <br>
     * In either case, the original value is put in the hints map.
     * So essential difference between setting a value directly by a setter and via a hint is the memory
     * of this original value.
     * <br>
     * The other important difference is setting lock levels. Setting of lock level via setter method needs
     * active transaction. But setting via hint does not.
     * @param key a hint key. If it is one of the statically registered hint key then the setter is called.
     * @param value to be set. The given value type must match the argument type of the setter, if one exists.
     * @param original value as specified by the caller. This value is put in the hints map.
     *
     * @exception IllegalArgumentException if the given value is not acceptable by the setter method, if one
     * exists corresponds the given hint key.
     */
    @Override
    public void setHint(String key, Object value, Object original) {
        if (key == null)
            return;
        if (_hintSetters.containsKey(key)) {
            Method setter = _hintSetters.get(key);
            String methodName = setter.getName();
            try {
                if ("setReadLockLevel".equals(methodName) && !isActiveTransaction()) {
                    _state.readLockLevel = (Integer)value;
                } else if ("setWriteLockLevel".equals(methodName) && !isActiveTransaction()) {
                    _state.writeLockLevel = (Integer)value;
                } else {
                    setter.invoke(this, Filters.convertToMatchMethodArgument(value, setter));
                }
            } catch (Exception e) {
                String message = _loc.get("bad-hint-value", key, toString(value), toString(original)).getMessage();
                if (e instanceof IllegalArgumentException) {
                    throw new IllegalArgumentException(message);
                }
                throw new IllegalArgumentException(message, e);
            }
        }
        addHint(key, original);
    }

    private void addHint(String name, Object value) {
        lock();
        try {
            if (_state.hints == null)
                _state.hints = new HashMap<>();
            _state.hints.put(name, value);
        } finally {
            unlock();
        }
    }

    @Override
    public Object getHint(String name) {
        return (_state.hints == null) ? null : _state.hints.get(name);
    }

    public Object removeHint(String name) {
        return (_state.hints == null) ? null : _state.hints.remove(name);
    }

    @Override
    public Set<Class<?>> getRootClasses() {
        if (_state.rootClasses == null) return Collections.emptySet();
        return _state.rootClasses;
    }

    @Override
    public FetchConfiguration setRootClasses(Collection<Class<?>> classes) {
        lock();
        try {
            if (_state.rootClasses != null)
                _state.rootClasses.clear();
            if (classes != null && !classes.isEmpty()) {
                if (_state.rootClasses == null)
                    _state.rootClasses = new HashSet<>(classes);
                else
                    _state.rootClasses.addAll(classes);
            }
        } finally {
            unlock();
        }
        return this;
    }

    @Override
    public Set<Object> getRootInstances() {
        if (_state.rootInstances == null) return Collections.emptySet();
        return _state.rootInstances;
    }

    @Override
    public FetchConfiguration setRootInstances(Collection<?> instances) {
        lock();
        try {
            if (_state.rootInstances != null)
                _state.rootInstances.clear();
            if (instances != null && !instances.isEmpty()) {
                if (_state.rootInstances == null) {
                    _state.rootInstances = new HashSet<>(instances);
                } else {
                    _state.rootInstances.addAll(instances);
                }
            }
        } finally {
            unlock();
        }
        return this;
    }

    @Override
    public void lock() {
        if (_state.ctx != null)
            _state.ctx.lock();
    }

    @Override
    public void unlock() {
        if (_state.ctx != null)
            _state.ctx.unlock();
    }

    /////////////
    // Traversal
    /////////////

    @Override
    public int requiresFetch(FieldMetaData fm) {
        if (!includes(fm))
            return FETCH_NONE;

        Class<?> type = fm.getRelationType();
        if (type == null)
            return FETCH_LOAD;
        if (_availableDepth == 0)
            return FETCH_NONE;

        // we can skip calculating recursion depth if this is a top-level conf:
        // the field is in our fetch groups, so can't possibly not select
        if (_parent == null)
            return FETCH_LOAD;

        String fieldName = fm.getFullName(false);
        int rdepth = getAvailableRecursionDepth(fm, type, fieldName, false);
        if (rdepth != FetchGroup.DEPTH_INFINITE && rdepth <= 0)
            return FETCH_NONE;

        if (Objects.equals(_directRelationOwner, fieldName))
            return FETCH_REF;
