/*
 * 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.persistence;

import java.sql.Time;
import java.sql.Timestamp;
import java.util.Calendar;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;

import jakarta.persistence.Parameter;
import jakarta.persistence.TemporalType;
import jakarta.persistence.TypedQuery;
import jakarta.persistence.criteria.ParameterExpression;

import org.apache.openjpa.kernel.Filters;
import org.apache.openjpa.kernel.QueryLanguages;
import org.apache.openjpa.lib.util.Localizer;
import org.apache.openjpa.lib.util.OrderedMap;
import org.apache.openjpa.meta.QueryMetaData;
import org.apache.openjpa.persistence.criteria.BindableParameter;

/**
 * An abstract implementation of the Query interface.
 */
public abstract class AbstractQuery<X> implements OpenJPAQuerySPI<X> {
    private static final Localizer _loc = Localizer.forPackage(AbstractQuery.class);

    protected boolean _relaxBindParameterTypeChecking;
    protected boolean _convertPositionalParams;

    // Will be null if this isn't a NamedQuery
    protected final QueryMetaData _qmd;

    protected transient EntityManagerImpl _em;

    protected Map<Parameter<?>, Object> _boundParams;
    protected Map<Object, Parameter<?>> _declaredParams;

    public AbstractQuery(QueryMetaData qmd, EntityManagerImpl em) {
        _qmd = qmd;
        _em = em;

        _boundParams = new HashMap<>();
    }

    /**
     * Gets a map of values of each parameter indexed by their <em>original</em> key.
     *
     * @return an empty map if no parameter is declared for this query. The unbound parameters has a value of null which
     *         is indistinguishable from the value being bound to null.
     */
    Map<Object, Object> getParameterValues() {
        Map<Object, Object> result = new HashMap<>();
        if (_boundParams == null)
            return result;
        for (Map.Entry<Object, Parameter<?>> entry : getDeclaredParameters().entrySet()) {
            Object paramKey = entry.getKey();
            Parameter<?> param = entry.getValue();
            result.put(paramKey, _boundParams.get(param));
        }
        return result;
    }

    public boolean isProcedure() {
        return QueryLanguages.LANG_STORED_PROC.equals(getLanguage());
    }

    public boolean isNative() {
        return QueryLanguages.LANG_SQL.equals(getLanguage());
    }

    protected abstract void assertOpen();

    protected abstract void lock();

    protected abstract void unlock();

   /**
    * @return a map of parameter name to type for this query.
    */
    protected abstract OrderedMap<Object, Class<?>> getParamTypes();

    // =================================================================================
    // Parameter processing routines
    // =================================================================================

    /**
     * Binds the parameter identified by the given position to the given value. The parameter are bound to a value in
     * the context of this query. The same parameter may be bound to a different value in the context of another
     * query. <br>
     * For non-native queries, the given position must be a valid position in the declared parameters. <br>
     * As native queries may not be parsed and hence their declared parameters may not be known, setting an positional
     * parameter has the side-effect of a positional parameter being declared.
     *
     * @param position
     *            positive, integer position of the parameter
     * @param value
     *            an assignment compatible value
     * @return the same query instance
     * @throws IllegalArgumentException
     *             if position does not correspond to a positional parameter of the query or if the argument is of
     *             incorrect type
     */
    @Override
    public OpenJPAQuery<X> setParameter(int pos, Object value) {
        if (_convertPositionalParams) {
            return setParameter("_" + pos, value);
        }

        assertOpen();
        _em.assertNotCloseInvoked();
        lock();
        try {
            if (pos < 1) {
                throw new IllegalArgumentException(_loc.get("illegal-index", pos).getMessage());
            }
            Parameter<?> param;
            if (isNative() || isProcedure()) {
                param = new ParameterImpl<>(pos, Object.class);
                declareParameter(pos, param);
            } else {
                param = getParameter(pos);
            }
            bindValue(param, value);

            return this;
        } finally {
            unlock();
        }
    }

    /**
     * Sets the value of the given positional parameter after conversion of the given value to the given Temporal Type.
     */
    @Override
    public OpenJPAQuery<X> setParameter(int position, Calendar value, TemporalType t) {
        return setParameter(position, convertTemporalType(value, t));
    }

    /**
     * Sets the value of the given named parameter after conversion of the given value to the given Temporal Type.
     */
    @Override
    public OpenJPAQuery<X> setParameter(int position, Date value, TemporalType type) {
        return setParameter(position, convertTemporalType(value, type));
    }

    /**
     * Converts the given Date to a value corresponding to given temporal type.
     */
    Object convertTemporalType(Date value, TemporalType type) {
        switch (type) {
        case DATE:
            return value;
        case TIME:
            return new Time(value.getTime());
        case TIMESTAMP:
            return new Timestamp(value.getTime());
        default:
            return null;
        }
    }

    Object convertTemporalType(Calendar value, TemporalType type) {
        return convertTemporalType(value.getTime(), type);
    }

    /**
     * Affirms if declared parameters use position identifier.
     */
    @Override
    public boolean hasPositionalParameters() {
        return !getDeclaredParameterKeys(Integer.class).isEmpty();
    }

    /**
     * Gets the array of positional parameter values. The n-th array element represents (n+1)-th positional parameter.
     * If a parameter has been declared but not bound to a value then the value is null and hence is indistinguishable
     * from the value being actually null. If the parameter indexing is not contiguous then the unspecified parameters
     * are considered as null.
     */
    @Override
    public Object[] getPositionalParameters() {
        lock();
        try {
            Set<Integer> positionalKeys = getDeclaredParameterKeys(Integer.class);
            Object[] result = new Object[calculateMaxKey(positionalKeys)];
            for (Integer pos : positionalKeys) {
                Parameter<?> param = getParameter(pos);
                result[pos - 1] = isBound(param) ? getParameterValue(pos) : null;
            }
            return result;
        } finally {
            unlock();
        }
    }

    /**
     * Calculate the maximum value of the given set.
     */
    int calculateMaxKey(Set<Integer> p) {
        if (p == null)
            return 0;
        int max = Integer.MIN_VALUE;
        for (Integer i : p)
            max = Math.max(max, i);
        return max;
    }

    /**
     * Binds the given values as positional parameters. The n-th array element value is set to a Parameter with (n+1)-th
     * positional identifier.
     */
    @Override
    public OpenJPAQuery<X> setParameters(Object... params) {
        assertOpen();
        _em.assertNotCloseInvoked();
        lock();
        try {
            clearBinding();
            for (int i = 0; params != null && i < params.length; i++) {
                setParameter(i + 1, params[i]);
            }
            return this;
        } finally {
            unlock();
        }
    }

    void clearBinding() {
        if (_boundParams != null)
            _boundParams.clear();
    }

    /**
     * Gets the value of all the named parameters.
     *
     * If a parameter has been declared but not bound to a value then the value is null and hence is indistinguishable
     * from the value being actually null.
     */
    @Override
    public Map<String, Object> getNamedParameters() {
        lock();
        try {
            Map<String, Object> result = new HashMap<>();
            Set<String> namedKeys = getDeclaredParameterKeys(String.class);
            for (String name : namedKeys) {
                Parameter<?> param = getParameter(name);
                result.put(name, isBound(param) ? getParameterValue(name) : null);
            }
            return result;
        } finally {
            unlock();
        }
    }

    /**
     * Sets the values of the parameters from the given Map. The keys of the given map designate the name of the
     * declared parameter.
     */
    @Override
    public OpenJPAQuery<X> setParameters(Map params) {
        assertOpen();
        _em.assertNotCloseInvoked();
        lock();
        try {
            clearBinding();
            if (params != null) {
                for (Map.Entry e : (Set<Map.Entry>) params.entrySet()) {
                    setParameter((String) e.getKey(), e.getValue());
                }
            }
            return this;
        } finally {
            unlock();
        }
    }

    /**
     * Get the parameter of the given name and type.
     *
     * @throws IllegalArgumentException
     *             if the parameter of the specified name does not exist or is not assignable to the type
     * @throws IllegalStateException
     *             if invoked on a native query
     */
    @Override
    public <T> Parameter<T> getParameter(String name, Class<T> type) {
        Parameter<?> param = getParameter(name);
        if (param.getParameterType().isAssignableFrom(type))
            throw new IllegalArgumentException(param + " does not match the requested type " + type);
        return (Parameter<T>) param;
    }

    /**
     * Get the positional parameter with the given position and type.
     *
     * @throws IllegalArgumentException
     *             if the parameter with the specified position does not exist or is not assignable to the type
     * @throws IllegalStateException
     *             if invoked on a native query unless the same parameter position is bound already.
     */
    @Override
    public <T> Parameter<T> getParameter(int pos, Class<T> type) {
        if (_convertPositionalParams) {
            return getParameter("_" + pos, type);
        }
        Parameter<?> param = getParameter(pos);
        if (param.getParameterType().isAssignableFrom(type))
            throw new IllegalArgumentException(param + " does not match the requested type " + type);
        return (Parameter<T>) param;
    }

    /**
     * Return the value bound to the parameter.
     *
     * @param param
     *            parameter object
     * @return parameter value
     * @throws IllegalStateException
     *             if the parameter has not been been bound
     * @throws IllegalArgumentException
     *             if the parameter does not belong to this query
     */
    @Override
    public <T> T getParameterValue(Parameter<T> p) {
        if (!isBound(p)) {
            throw new IllegalArgumentException(_loc.get("param-missing", p, getQueryString(), getBoundParameterKeys())
                .getMessage());
        }
        return (T) _boundParams.get(p);
    }

    /**
     * Gets the parameters declared in this query.
     */
    @Override
    public Set<Parameter<?>> getParameters() {
        Set<Parameter<?>> result = new HashSet<>(getDeclaredParameters().values());
        return result;
    }

    @Override
    public <T> OpenJPAQuery<X> setParameter(Parameter<T> p, T arg1) {
        bindValue(p, arg1);
        if (BindableParameter.class.isInstance(p)) {
            BindableParameter.class.cast(p).setValue(arg1);
        }
        return this;
    }

    @Override
    public OpenJPAQuery<X> setParameter(Parameter<Date> p, Date date, TemporalType type) {
        return setParameter(p, (Date) convertTemporalType(date, type));
    }

    @Override
    public TypedQuery<X> setParameter(Parameter<Calendar> p, Calendar cal, TemporalType type) {
        return setParameter(p, (Calendar) convertTemporalType(cal, type));
    }

    /**
     * Get the parameter object corresponding to the declared parameter of the given name. This method is not required
     * to be supported for native queries.
     *
     * @throws IllegalArgumentException
     *             if the parameter of the specified name does not exist
     * @throws IllegalStateException
     *             if invoked on a native query
     */
    @Override
    public Parameter<?> getParameter(String name) {
        if (isNative()) {
            throw new IllegalStateException(_loc.get("param-named-non-native", name).getMessage());
        }
        Parameter<?> param = getDeclaredParameters().get(name);
        if (param == null) {
            Set<ParameterExpression> exps = getDeclaredParameterKeys(ParameterExpression.class);
            for (ParameterExpression<?> e : exps) {
                if (name.equals(e.getName()))
                    return e;
            }
            throw new IllegalArgumentException(_loc.get("param-missing-name", name, getQueryString(),
                getDeclaredParameterKeys()).getMessage());
        }
        return param;
    }

    /**
     * Get the positional parameter with the given position. The parameter may just have been declared and not bound to
     * a value.
     *
     * @param position
     *            specified in the user query.
     * @return parameter object
     * @throws IllegalArgumentException
     *             if the parameter with the given position does not exist
     */
    @Override
    public Parameter<?> getParameter(int pos) {
        if (_convertPositionalParams) {
            return getParameter("_" + pos);
        }
        Parameter<?> param = getDeclaredParameters().get(pos);
        if (param == null)
            throw new IllegalArgumentException(_loc.get("param-missing-pos", pos, getQueryString(),
                getDeclaredParameterKeys()).getMessage());
        return param;
    }

    /**
     * Return the value bound to the parameter.
     *
     * @param name
     *            name of the parameter
     * @return parameter value
     *
     * @throws IllegalStateException
     *             if this parameter has not been bound
     */
    @Override
    public Object getParameterValue(String name) {
        return _boundParams.get(getParameter(name));
    }

    /**
     * Return the value bound to the parameter.
     *
     * @param pos
     *            position of the parameter
     * @return parameter value
     *
     * @throws IllegalStateException
     *             if this parameter has not been bound
     */
    @Override
    public Object getParameterValue(int pos) {
        Parameter<?> param = getParameter(pos);
        assertBound(param);
        return _boundParams.get(param);
    }

    /**
     * Gets the parameter keys bound with this query. Parameter key can be Integer, String or a ParameterExpression
     * itself but all parameters keys of a particular query are of the same type.
     */
    public Set<?> getBoundParameterKeys() {
        if (_boundParams == null)
            return Collections.EMPTY_SET;
        getDeclaredParameters();
        Set<Object> result = new HashSet<>();
        for (Map.Entry<Object, Parameter<?>> entry : _declaredParams.entrySet()) {
            if (isBound(entry.getValue())) {
                result.add(entry.getKey());
            }
        }
        return result;
    }

    /**
     * Gets the declared parameter keys in the given query. This information is only available after the query has been
     * parsed. As native language queries are not parsed, this information is not available for them.
     *
     * @return set of parameter identifiers in a parsed query
     */
    public Set<?> getDeclaredParameterKeys() {
        return getDeclaredParameters().keySet();
    }

    public <T> Set<T> getDeclaredParameterKeys(Class<T> keyType) {
        Set<T> result = new HashSet<>();
        for (Object key : getDeclaredParameterKeys()) {
            if (keyType.isInstance(key))
                result.add((T) key);
        }
        return result;
    }

    /**
     * Gets the parameter instances declared in this query. All parameter keys are of the same type. It is not allowed
     * to mix keys of different type such as named and positional keys.
     *
     * For string-based queries, the parser supplies the information about the declared parameters as a LinkedMap of
     * expected parameter value type indexed by parameter identifier. For non string-based queries that a facade itself
     * may construct (e.g. CriteriaQuery), the parameters must be declared by the caller. This receiver constructs
     * concrete Parameter instances from the given parameter identifiers.
     *
     * @return a Map where the key represents the original identifier of the parameter (can be a String, Integer or a
     *         ParameterExpression itself) and the value is the concrete Parameter instance either constructed as a
     *         result of this call or supplied by declaring the parameter explicitly via
     *         {@linkplain #declareParameter(Parameter)}.
     */
    public Map<Object, Parameter<?>> getDeclaredParameters() {
        if (_declaredParams == null) {
            _declaredParams = new HashMap<>();

            OrderedMap<Object, Class<?>> paramTypes = null;
            // Check to see if we have a cached version of the paramTypes in QueryMetaData.
            if (_qmd != null) {
                paramTypes = _qmd.getParamTypes();
            }
            if (paramTypes == null) {
                paramTypes = getParamTypes();
                // Cache the param types as they haven't been set yet.
                if (_qmd != null) {
                    _qmd.setParamTypes(paramTypes);
                }
            }
            for (Entry<Object, Class<?>> entry : paramTypes.entrySet()) {
                Object key = entry.getKey();
                Class<?> expectedValueType = entry.getValue();
                Parameter<?> param;

                if (key instanceof Integer) {
                    param = new ParameterImpl((Integer) key, expectedValueType);
                } else if (key instanceof String) {
                    param = new ParameterImpl((String) key, expectedValueType);
                } else if (key instanceof Parameter) {
                    param = (Parameter<?>) key;
                } else {
                    throw new IllegalArgumentException("parameter identifier " + key + " unrecognized");
                }
                declareParameter(key, param);
            }
        }
        return _declaredParams;
    }

    /**
     * Declares the given parameter for this query. Used by non-string based queries that are constructed by the facade
     * itself rather than OpenJPA parsing the query to detect the declared parameters.
     *
     * @param key
     *            this is the key to identify the parameter later in the context of this query. Valid key types are
     *            Integer, String or ParameterExpression itself.
     * @param the
     *            parameter instance to be declared
     */
    public void declareParameter(Object key, Parameter<?> param) {
        if (_declaredParams == null) {
            _declaredParams = new HashMap<>();
        }
        _declaredParams.put(key, param);
    }

    /**
     * Affirms if the given parameter is bound to a value for this query.
     */
    @Override
    public boolean isBound(Parameter<?> param) {
        return _boundParams != null && _boundParams.containsKey(param);
    }

    void assertBound(Parameter<?> param) {
        if (!isBound(param)) {
            throw new IllegalStateException(_loc.get("param-not-bound", param, getQueryString(),
                getBoundParameterKeys()).getMessage());
        }
    }

    /**
     * Binds the given value to the given parameter. Validates if the parameter can accept the value by its type.
     */
    void bindValue(Parameter<?> param, Object value) {
        Object bindVal = assertValueAssignable(param, value);
        _boundParams.put(param, bindVal);
    }

    @Override
    public OpenJPAQuery<X> setParameter(String name, Calendar value, TemporalType type) {
        return setParameter(name, convertTemporalType(value, type));
    }

    @Override
    public OpenJPAQuery<X> setParameter(String name, Date value, TemporalType type) {
        return setParameter(name, convertTemporalType(value, type));
    }

    /**
     * Sets the parameter of the given name to the given value.
     */
    @Override
    public OpenJPAQuery<X> setParameter(String name, Object value) {
        assertOpen();
        _em.assertNotCloseInvoked();
        lock();
        try {
            // native queries can not have named parameters
            if (isNative()) {
                throw new IllegalArgumentException(_loc.get("no-named-params", name, getQueryString()).toString());
            } else {
                bindValue(getParameter(name), value);
            }

            return this;
        } finally {
            unlock();
        }
    }

    /**
     * Convert the given value to match the given parameter type, if possible.
     *
     * @param param
     *            a query parameter
     * @param v
     *            a user-supplied value for the parameter
     */
    Object assertValueAssignable(Parameter<?> param, Object v) {
        Class<?> expectedType = param.getParameterType();
        if (v == null) {
            if (expectedType.isPrimitive())
                throw new IllegalArgumentException(_loc.get("param-null-primitive", param).getMessage());
            return v;
        }
        if (getRelaxBindParameterTypeChecking()) {
            try {
                return Filters.convert(v, expectedType);
            } catch (Exception e) {
                throw new IllegalArgumentException(_loc.get("param-type-mismatch",
                    new Object[] { param, getQueryString(), v, v.getClass().getName(), expectedType.getName() })
                    .getMessage());
            }
        } else {
            if (!Filters.canConvert(v.getClass(), expectedType, true)) {
                throw new IllegalArgumentException(_loc.get("param-type-mismatch",
                    new Object[] { param, getQueryString(), v, v.getClass().getName(), expectedType.getName() })
                    .getMessage());
            } else {
                return v;
            }
        }
    }

    // ================== End of Parameter Processing routines ================================

    @Override
    public boolean getRelaxBindParameterTypeChecking() {
        return _relaxBindParameterTypeChecking;
    }

    @Override
    public void setRelaxBindParameterTypeChecking(Object value) {
        if (value != null) {
            if (value instanceof String) {
                _relaxBindParameterTypeChecking = "true".equalsIgnoreCase(value.toString());
            } else if (value instanceof Boolean) {
                _relaxBindParameterTypeChecking = (Boolean) value;
            }
        }
    }
}
