/*
 * Copyright (c) 2008-2018, Hazelcast, Inc. All Rights Reserved.
 *
 * Licensed 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.springframework.data.hazelcast.repository.query;

import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.SliceImpl;
import org.springframework.data.domain.Sort;
import org.springframework.data.keyvalue.core.IterableConverter;
import org.springframework.data.keyvalue.core.KeyValueOperations;
import org.springframework.data.keyvalue.core.query.KeyValueQuery;
import org.springframework.data.keyvalue.repository.query.KeyValuePartTreeQuery;
import org.springframework.data.repository.query.Parameter;
import org.springframework.data.repository.query.Parameters;
import org.springframework.data.repository.query.ParametersParameterAccessor;
import org.springframework.data.repository.query.QueryMethod;
import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider;
import org.springframework.data.repository.query.RepositoryQuery;
import org.springframework.data.repository.query.parser.AbstractQueryCreator;
import org.springframework.data.repository.query.parser.Part;
import org.springframework.data.repository.query.parser.PartTree;
import org.springframework.data.util.StreamUtils;
import org.springframework.util.Assert;

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.stream.Collectors;

/**
 * <p>
 * There is one instance for each query method defined for a repository, providing a query from the bind parameters.
 * </P>
 * <p>
 * TODO The {@link #execute} method calls the {@link #prepareQuery} to bind the parameters to the query. This results in
 * Hazelcast's {@link com.hazelcast.query.PredicateBuilder PredicateBuilder} being called for each query execution. A
 * more efficient mechanism would be to some sort of templating that would allow the parameters to be instantiated into
 * the output of the predicate builder rather than the input.
 * </P>
 *
 * @author Neil Stevenson
 * @author Viacheslav Petriaiev
 */
public class HazelcastPartTreeQuery
        extends KeyValuePartTreeQuery {

    private final QueryMethod queryMethod;
    private final KeyValueOperations keyValueOperations;

    private boolean isCount;
    private boolean isDelete;
    private boolean isDistinct;
    private boolean isExists;

    private boolean isRearrangeKnown;
    private boolean isRearrangeRequired;
    private int[] rearrangeIndex;

    /**
     * <p>
     * Create a {@link RepositoryQuery} implementation for each query method defined in a {@link HazelcastRepository}.
     * </P>
     *
     * @param queryMethod                Method defined in {@code HazelcastRepository}
     * @param evaluationContextProvider  Not used
     * @param keyValueOperations         Interface to Hazelcast
     * @param queryCreator               Not used
     */
    public HazelcastPartTreeQuery(QueryMethod queryMethod, QueryMethodEvaluationContextProvider evaluationContextProvider,
                                  KeyValueOperations keyValueOperations,
                                  Class<? extends AbstractQueryCreator<?, ?>> queryCreator) {
        super(queryMethod, evaluationContextProvider, keyValueOperations, queryCreator);
        this.queryMethod = queryMethod;
        this.keyValueOperations = keyValueOperations;

        this.isRearrangeKnown = false;
    }

    /**
     * <p>
     * Execute this query instance, using any invocation parameters.
     * </P>
     * <p>
     * Expecting {@code findBy...()}, {@code countBy...()} or {@code deleteBy...()}
     * </P>
     *
     * @param parameters Any parameters
     * @return Query result
     */
    @Override
    public Object execute(Object[] parameters) {

        KeyValueQuery<?> query = prepareQuery(parameters);

        if (this.isCount) {
            final Class<?> javaType = queryMethod.getEntityInformation().getJavaType();
            if (this.isDistinct) {
                final Iterable<?> iterable = this.keyValueOperations.find(query, javaType);
                return  StreamUtils.createStreamFromIterator(iterable.iterator()).distinct().count();
            } else {
                return this.keyValueOperations.count(query, javaType);
            }
        }

        if (this.isDelete) {
            return this.executeDeleteQuery(query, queryMethod);
        }

        if (this.isExists) {
            query.setOffset(0);
            query.setRows(1);
            final Iterable<?> result = this.keyValueOperations.find(query, queryMethod.getEntityInformation().getJavaType());
            return result.iterator().hasNext();
        }

        if (queryMethod.isPageQuery() || queryMethod.isSliceQuery()) {
            return this.executePageSliceQuery(parameters, query, queryMethod);
        }

        if (queryMethod.isCollectionQuery() || queryMethod.isQueryForEntity() || queryMethod.isStreamQuery()) {
            return this.executeFindQuery(query, queryMethod, this.isDistinct);
        }

        String message = String.format("Query method '%s' not supported.", queryMethod.getName());
        throw new UnsupportedOperationException(message);
    }

    /**
     * Execute a "delete" query, not really a query more of an operation.
     * <p>
     *
     * @param query       The query to run
     * @param queryMethod Used here to find the type of object to match the query
     * @return Collection of deleted objects or the number of deleted objects
     */
    private Object executeDeleteQuery(final KeyValueQuery<?> query, final QueryMethod queryMethod) {

        Iterable<?> resultSet = this.keyValueOperations.find(query, queryMethod.getEntityInformation().getJavaType());
        Iterator<?> iterator = resultSet.iterator();

        List<Object> result = new ArrayList<>();
        while (iterator.hasNext()) {
            result.add(this.keyValueOperations.delete(iterator.next()));
        }

        if (queryMethod.isCollectionQuery()) {
            return result;
        } else if (long.class.equals(queryMethod.getReturnedObjectType()) || Long.class
                .equals(queryMethod.getReturnedObjectType())) {
            return result.size();
        } else {
            throw new UnsupportedOperationException(String.format(
                    "Illegal returned type: %s. The operation 'deleteBy' accepts only 'long' and 'Collection' as the returned "
                            + "object type", queryMethod.getReturnedObjectType()));
        }
    }

    /**
     * <p>
     * Execute a retrieval query. The query engine will return this in an iterator, which may need conversion to a single
     * domain entity or a stream.
     * </P>
     *
     * @param query       The query to run
     * @param queryMethod Holds metadata about the query, is paging etc
     * @return Query result
     */
    private Object executeFindQuery(final KeyValueQuery<?> query, final QueryMethod queryMethod, final boolean distinct) {

        Iterable<?> resultSet = this.keyValueOperations.find(query, queryMethod.getEntityInformation().getJavaType());

        if (!queryMethod.isCollectionQuery() && !queryMethod.isPageQuery() && !queryMethod.isSliceQuery() && !queryMethod
                .isStreamQuery()) {
            // Singleton result
            return resultSet.iterator().hasNext() ? resultSet.iterator().next() : null;
        }

        if (queryMethod.isStreamQuery()) {
            if (distinct) {
                return StreamUtils.createStreamFromIterator(resultSet.iterator()).distinct();
            }
            return StreamUtils.createStreamFromIterator(resultSet.iterator());
        }

        if (distinct) {
            return StreamUtils.createStreamFromIterator(resultSet.iterator()).distinct().collect(Collectors.toList());
        }

        return resultSet;
    }

    /**
     * <p>
     * Slices and pages are similar ways to iterate through the result set in blocks, mimicking a cursor. A
     * {@link org.springframework.data.domain.Slice Slice} is a simpler concept, only requiring to know if further blocks
     * of data are available. A {@link org.springframework.data.domain.Page Page} requires to know how many blocks of data
     * are available in total.
     * </P>
     *
     * @param parameters  For the query
     * @param query       The query to run
     * @param queryMethod Holds metadata about the query
     * @return Query result
     */
    @SuppressWarnings({"rawtypes", "unchecked"})
    private Object executePageSliceQuery(final Object[] parameters, final KeyValueQuery<?> query, final QueryMethod queryMethod) {
        long totalElements = -1;

        int indexOfPageRequest = queryMethod.getParameters().getPageableIndex();
        Pageable pageRequest = (Pageable) parameters[indexOfPageRequest];

        /* TODO Eliminate count call for Slice, retrieve "rows+1" instead to determine if next page exists.
         */
        if (query.getCriteria() == null) {
            totalElements = this.keyValueOperations.count(queryMethod.getEntityInformation().getJavaType());
        } else {
            totalElements = this.keyValueOperations.count(query, queryMethod.getEntityInformation().getJavaType());
        }

        int requiredRows = pageRequest.getPageSize();

        query.setOffset(pageRequest.getOffset());
        query.setRows(pageRequest.getPageSize());

        Iterable<?> resultSet = this.keyValueOperations.find(query, queryMethod.getEntityInformation().getJavaType());
        List<?> content = IterableConverter.toList(resultSet);

        if (queryMethod.isPageQuery()) {
            return new PageImpl(content, pageRequest, totalElements);
        } else {
            boolean hasNext = totalElements > (query.getOffset() + query.getRows());
            if (content.size() > requiredRows) {
                content = content.subList(0, requiredRows);
            }
            return new SliceImpl(content, pageRequest, hasNext);
        }
    }

    /**
     * <p>
     * Create the query from the bind parameters.
     * </P>
     *
     * @param parameters Possibly empty list of query parameters
     * @return A ready-to-use query
     */
    protected KeyValueQuery<?> prepareQuery(Object[] parameters) {
        PartTree tree = null;

        if (this.queryMethod.getParameters().getNumberOfParameters() > 0) {
            tree = new PartTree(getQueryMethod().getName(), getQueryMethod().getEntityInformation().getJavaType());
            this.isCount = tree.isCountProjection();
            this.isDelete = tree.isDelete();
            this.isDistinct = tree.isDistinct();
            this.isExists = tree.isExistsProjection();
        } else {
            this.isCount = false;
            this.isDelete = false;
            this.isDistinct = false;
            this.isExists = false;
        }

        ParametersParameterAccessor accessor = this.prepareAccessor(parameters, tree);

        KeyValueQuery<?> query = createQuery(accessor);

        if (accessor.getPageable() != Pageable.unpaged()) {
            query.setOffset(accessor.getPageable().getOffset());
            query.setRows(accessor.getPageable().getPageSize());
        } else {
            query.setOffset(-1);
            query.setRows(-1);
        }

        if (accessor.getSort() != Sort.unsorted()) {
            query.setSort(accessor.getSort());
        }

        return query;
    }

    /**
     * <p>
     * Handle {@code @Param}.
     * </P>
     * <OL>
     * <LI><B>Without {@code @Param}</B>
     * <p>
     * Arguments to the call are assumed to follow the same sequence as cited in the method name.
     * </P>
     * <p>
     * Eg.
     * <p>
     * <pre>
     * findBy<U>One</U>And<U>Two</U>(String <U>one</U>, String <U>two</U>);
     * </pre>
     * </P>
     * </LI>
     * <LI><B>With {@code @Param}</B>
     * <p>
     * Arguments to the call are use the {@code @Param} to match them against the fields.
     * <p>
     * Eg.
     * <p>
     * <pre>
     * findBy<U>One</U>And<U>Two</U>(@Param("two") String <U>two</U>, @Param("one") String <U>one</U>);
     * </pre>
     * </P>
     * </LI>
     * </OL>
     *
     * @param originalParameters Possibly empty
     * @param partTree           Query tree to traverse
     * @return Parameters in correct order
     */
    private ParametersParameterAccessor prepareAccessor(final Object[] originalParameters, final PartTree partTree) {

        if (!this.isRearrangeKnown) {
            this.prepareRearrange(partTree, this.queryMethod.getParameters().getBindableParameters());
            this.isRearrangeKnown = true;
        }

        Object[] parameters = originalParameters;
        Assert.notNull(parameters, "Parameters must not be null.");

        if (this.isRearrangeRequired) {
            parameters = new Object[originalParameters.length];

            for (int i = 0; i < parameters.length; i++) {
                int index = (i < rearrangeIndex.length) ? rearrangeIndex[i] : i;
                parameters[i] = originalParameters[index];
            }
        }

        return new ParametersParameterAccessor(this.queryMethod.getParameters(), parameters);
    }

    /**
     * <p>
     * Determine if the arguments to the method need reordered.
     * </P>
     * <p>
     * For searches such as {@code findBySomethingNotNull} there may be more parts than parameters needed to be bound to
     * them.
     * </P>
     *
     * @param partTree           Query parts
     * @param bindableParameters Parameters expected
     */
    @SuppressWarnings("unchecked")
    private void prepareRearrange(final PartTree partTree, final Parameters<?, ?> bindableParameters) {

        this.isRearrangeRequired = false;
        if (partTree == null || bindableParameters == null) {
            return;
        }

        List<String> queryParams = new ArrayList<>();
        List<String> methodParams = new ArrayList<>();

        for (Part part : partTree.getParts()) {
            queryParams.add(part.getProperty().getSegment());
        }

        Iterator<Parameter> bindableParameterIterator = (Iterator<Parameter>) bindableParameters.iterator();
        while (bindableParameterIterator.hasNext()) {
            Parameter parameter = bindableParameterIterator.next();
            parameter.getName().ifPresent(methodParams::add);
        }

        this.rearrangeIndex = new int[queryParams.size()];

        String[] paramsExpected = queryParams.toArray(new String[0]);
        String[] paramsProvided = methodParams.toArray(new String[0]);

        for (int i = 0; i < this.rearrangeIndex.length; i++) {
            this.rearrangeIndex[i] = i;

            for (int j = 0; j < paramsProvided.length; j++) {
                if (paramsProvided[j] != null && paramsProvided[j].equals(paramsExpected[i])) {
                    this.rearrangeIndex[i] = j;
                    this.isRearrangeRequired = true;
                }
            }
        }
    }
}