/*
 * 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.deltaspike.data.impl.handler;

import org.apache.deltaspike.data.api.EntityRepository;
import org.apache.deltaspike.data.api.Query;
import org.apache.deltaspike.data.impl.builder.QueryBuilder;
import org.apache.deltaspike.data.impl.meta.RequiresTransaction;
import org.apache.deltaspike.data.impl.property.Property;
import org.apache.deltaspike.data.impl.property.query.NamedPropertyCriteria;
import org.apache.deltaspike.data.impl.property.query.PropertyQueries;
import org.apache.deltaspike.data.impl.util.EntityUtils;
import org.apache.deltaspike.data.impl.util.jpa.PersistenceUnitUtilDelegateFactory;
import org.apache.deltaspike.data.spi.DelegateQueryHandler;

import jakarta.enterprise.context.Dependent;
import jakarta.inject.Inject;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceUnitUtil;
import jakarta.persistence.QueryHint;
import jakarta.persistence.TypedQuery;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.metamodel.SingularAttribute;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.logging.Level;
import java.util.logging.Logger;

import static org.apache.deltaspike.core.util.ArraysUtils.isEmpty;
import static org.apache.deltaspike.data.impl.util.QueryUtils.isString;

/**
 * Implement basic functionality from the {@link EntityRepository}.
 *
 * @param <E>  Entity type.
 * @param <PK> Primary key type, must be a serializable.
 */
@Dependent
public class EntityRepositoryHandler<E, PK extends Serializable>
        implements EntityRepository<E, PK>, DelegateQueryHandler
{

    private static final Logger log = Logger.getLogger(EntityRepositoryHandler.class.getName());

    @Inject
    private CdiQueryInvocationContext context;

    @Override
    @RequiresTransaction
    public E save(E entity)
    {
        if (context.isNew(entity))
        {
            entityManager().persist(entity);
            return entity;
        }
        return entityManager().merge(entity);
    }

    @Override
    @RequiresTransaction
    public E saveAndFlush(E entity)
    {
        E result = save(entity);
        flush();
        return result;
    }

    @Override
    @RequiresTransaction
    public E saveAndFlushAndRefresh(E entity)
    {
        E result = saveAndFlush(entity);
        entityManager().refresh(result);
        return result;
    }

    @Override
    @RequiresTransaction
    public void refresh(E entity)
    {
        entityManager().refresh(entity);
    }

    @Override
    public E findBy(PK primaryKey)
    {
        Query query = context.getMethod().getAnnotation(Query.class);
        if (query != null && query.hints().length > 0)
        {
            Map<String, Object> hints = new HashMap<String, Object>();
            for (QueryHint hint : query.hints())
            {
                hints.put(hint.name(), hint.value());
            }
            return entityManager().find(entityClass(), primaryKey, hints);
        }
        else
        {
            return entityManager().find(entityClass(), primaryKey);
        }
    }

    @Override
    public Optional<E> findOptionalBy(PK primaryKey)
    {
        E found = null;
        try
        {
            found = findBy(primaryKey);
        }
        catch (Exception e)
        {
        }
        return Optional.ofNullable(found);
    }

    @Override
    public List<E> findBy(E example, SingularAttribute<E, ?>... attributes)
    {
        return findBy(example, -1, -1, attributes);
    }

    @Override
    public List<E> findBy(E example, int start, int max, SingularAttribute<E, ?>... attributes)
    {
        return executeExampleQuery(example, start, max, false, attributes);
    }

    @Override
    public List<E> findByLike(E example, SingularAttribute<E, ?>... attributes)
    {
        return findByLike(example, -1, -1, attributes);
    }

    @Override
    public List<E> findByLike(E example, int start, int max, SingularAttribute<E, ?>... attributes)
    {
        return executeExampleQuery(example, start, max, true, attributes);
    }

    @SuppressWarnings("unchecked")
    @Override
    public List<E> findAll()
    {
        return context.applyRestrictions(entityManager().createQuery(allQuery(), entityClass())).getResultList();
    }

    @SuppressWarnings("unchecked")
    @Override
    public List<E> findAll(int start, int max)
    {
        TypedQuery<E> query = entityManager().createQuery(allQuery(), entityClass());
        if (start > 0)
        {
            query.setFirstResult(start);
        }
        if (max > 0)
        {
            query.setMaxResults(max);
        }
        return context.applyRestrictions(query).getResultList();
    }

    @Override
    public Long count()
    {
        return (Long) context.applyRestrictions(entityManager().createQuery(countQuery(), Long.class))
                .getSingleResult();
    }

    @Override
    public Long count(E example, SingularAttribute<E, ?>... attributes)
    {
        return executeCountQuery(example, false, attributes);
    }

    @Override
    public Long countLike(E example, SingularAttribute<E, ?>... attributes)
    {
        return executeCountQuery(example, true, attributes);
    }

    @SuppressWarnings("unchecked")
    @Override
    public PK getPrimaryKey(E entity)
    {
        return (PK) persistenceUnitUtil().getIdentifier(entity);
    }

    @Override
    @RequiresTransaction
    public void remove(E entity)
    {
        entityManager().remove(entity);
    }

    @Override
    @RequiresTransaction
    public void removeAndFlush(E entity)
    {
        entityManager().remove(entity);
        flush();
    }

    @Override
    @RequiresTransaction
    public void attachAndRemove(E entity)
    {
        if (!entityManager().contains(entity))
        {
            entity = entityManager().merge(entity);
        }
        remove(entity);
    }

    @Override
    @RequiresTransaction
    public void flush()
    {
        entityManager().flush();
    }

    public EntityManager entityManager()
    {
        return context.getEntityManager();
    }

    public CriteriaQuery<E> criteriaQuery()
    {
        return entityManager().getCriteriaBuilder().createQuery(entityClass());
    }

    public TypedQuery<E> typedQuery(String qlString)
    {
        return entityManager().createQuery(qlString, entityClass());
    }

    @SuppressWarnings("unchecked")
    public Class<E> entityClass()
    {
        return (Class<E>) context.getEntityClass();
    }

    public String tableName()
    {
        return EntityUtils.tableName(context.getEntityClass(), entityManager());
    }

    public String entityName()
    {
        return context.getEntityMetadata().getEntityName();
    }

    // ----------------------------------------------------------------------------
    // PRIVATE
    // ----------------------------------------------------------------------------

    private String allQuery()
    {
        return QueryBuilder.selectQuery(entityName());
    }

    private String countQuery()
    {
        return QueryBuilder.countQuery(entityName());
    }

    private String exampleQuery(String queryBase, List<Property<Object>> properties, boolean useLikeOperator)
    {
        StringBuilder jpqlQuery = new StringBuilder(queryBase).append(" where ");
        jpqlQuery.append(prepareWhere(properties, useLikeOperator));
        return jpqlQuery.toString();
    }

    private void addParameters(TypedQuery<?> query, E example, List<Property<Object>> properties,
                               boolean useLikeOperator)
    {
        for (Property<Object> property : properties)
        {
            property.setAccessible();
            query.setParameter(property.getName(), transform(property.getValue(example), useLikeOperator));
        }
    }

    private Object transform(Object value, final boolean useLikeOperator)
    {
        if (value != null && useLikeOperator && isString(value))
        {
            // seems to be an OpenJPA bug:
            // parameters in querys fail validation, e.g. UPPER(e.name) like UPPER(:name)
            String result = ((String) value).toUpperCase();
            return "%" + result + "%";
        }
        return value;
    }

    private String prepareWhere(List<Property<Object>> properties, boolean useLikeOperator)
    {
        Iterator<Property<Object>> iterator = properties.iterator();
        StringBuilder result = new StringBuilder();
        while (iterator.hasNext())
        {
            Property<Object> property = iterator.next();
            String name = property.getName();
            if (useLikeOperator && property.getJavaClass().getName().equals(String.class.getName()))
            {
                result.append("UPPER(e.").append(name).append(") like :").append(name)
                        .append(iterator.hasNext() ? " and " : "");
            }
            else
            {
                result.append("e.").append(name).append(" = :").append(name).append(iterator.hasNext() ? " and " : "");
            }
        }
        return result.toString();
    }

    private List<String> extractPropertyNames(SingularAttribute<E, ?>... attributes)
    {
        List<String> result = new ArrayList<String>(attributes.length);
        for (SingularAttribute<E, ?> attribute : attributes)
        {
            result.add(attribute.getName());
        }
        return result;
    }

    private List<Property<Object>> extractProperties(SingularAttribute<E, ?>... attributes)
    {
        List<String> names = extractPropertyNames(attributes);
        List<Property<Object>> properties = PropertyQueries.createQuery(entityClass())
                .addCriteria(new NamedPropertyCriteria(names.toArray(new String[]{}))).getResultList();
        return properties;
    }

    private List<E> executeExampleQuery(E example, int start, int max, boolean useLikeOperator,
                                        SingularAttribute<E, ?>... attributes)
    {
        // Not sure if this should be the intended behaviour
        // when we don't get any attributes maybe we should
        // return a empty list instead of all results
        if (isEmpty(attributes))
        {
            return findAll(start, max);
        }

        List<Property<Object>> properties = extractProperties(attributes);
        String jpqlQuery = exampleQuery(allQuery(), properties, useLikeOperator);
        log.log(Level.FINER, "findBy|findByLike: Created query {0}", jpqlQuery);
        TypedQuery<E> query = entityManager().createQuery(jpqlQuery, entityClass());

        // set starting position
        if (start > 0)
        {
            query.setFirstResult(start);
        }

        // set maximum results
        if (max > 0)
        {
            query.setMaxResults(max);
        }

        context.applyRestrictions(query);
        addParameters(query, example, properties, useLikeOperator);
        return query.getResultList();
    }

    private Long executeCountQuery(E example, boolean useLikeOperator, SingularAttribute<E, ?>... attributes)
    {
        if (isEmpty(attributes))
        {
            return count();
        }
        List<Property<Object>> properties = extractProperties(attributes);
        String jpqlQuery = exampleQuery(countQuery(), properties, useLikeOperator);
        log.log(Level.FINER, "count: Created query {0}", jpqlQuery);
        TypedQuery<Long> query = entityManager().createQuery(jpqlQuery, Long.class);
        addParameters(query, example, properties, useLikeOperator);
        context.applyRestrictions(query);
        return query.getSingleResult();
    }

    private PersistenceUnitUtil persistenceUnitUtil()
    {
        return PersistenceUnitUtilDelegateFactory.get(entityManager());
    }
}
