package com.thinkbiganalytics.metadata.jpa.support;

/*-
 * #%L
 * thinkbig-operational-metadata-jpa
 * %%
 * Copyright (C) 2017 ThinkBig Analytics
 * %%
 * 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.
 * #L%
 */

import com.google.common.collect.ImmutableCollection;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableMultimap;
import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.types.Operator;
import com.querydsl.core.types.Ops;
import com.querydsl.core.types.Path;
import com.querydsl.core.types.dsl.ComparablePath;
import com.querydsl.core.types.dsl.EntityPathBase;
import com.querydsl.core.types.dsl.Expressions;
import com.querydsl.core.types.dsl.NumberPath;
import com.querydsl.core.types.dsl.StringPath;
import com.thinkbiganalytics.Formatters;
import com.thinkbiganalytics.metadata.api.SearchCriteria;

import org.apache.commons.beanutils.BeanUtilsBean;
import org.apache.commons.beanutils.ConvertUtilsBean;
import org.apache.commons.lang3.StringUtils;
import org.joda.time.DateTime;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

/**
 * Apply a Filter to a given QueryDSL object Filter based upon supplying a list of {@code SearchCriteria} classes or a Filter String: <column><operator><value> where <column> is the jpaColumn name,
 * <operator> is a valid {@code this#operators} Operator, and <value> is some string value
 *
 * Example usage in a Provider:
 *
 * public Page<? extends BatchJobExecution> findAll(String filter, Pageable pageable) {
 * QJpaBatchJobExecution jobExecution = QJpaBatchJobExecution.jpaBatchJobExecution;
 * return jobExecutionRepository.findAll(GenericQueryDslFilter.buildFilter(jobExecution, filter), pageable);
 * }
 */
public class GenericQueryDslFilter {

    /**
     * Map of the String operator to respective QueryDSL operator
     */
    static final ImmutableMap<String, Operator> operators =
        new ImmutableMap.Builder<String, Operator>()
            .put("==", Ops.EQ_IGNORE_CASE)
            .put("!=", Ops.NE)
            .put(">=", Ops.GOE)
            .put("<=", Ops.LOE)
            .put("=~", Ops.LIKE_IC)
            .put(">", Ops.GT)
            .put("<", Ops.LT)
            .build();
    /**
     * Map of the QueryDSL operator to its respective QueryDSL path classes Some operators only work with specific Types This map will be used in validation when an operator of a filter is added to
     * ensure it will be applied correctly in the Query
     */
    static final ImmutableMultimap<Operator, Class<? extends Path>> operatorPaths =
        new ImmutableMultimap.Builder<Operator, Class<? extends Path>>()
            .put(Ops.GT, NumberPath.class)
            .put(Ops.GT, ComparablePath.class)
            .put(Ops.LT, NumberPath.class)
            .put(Ops.LT, ComparablePath.class)
            .put(Ops.GOE, NumberPath.class)
            .put(Ops.GOE, ComparablePath.class)
            .put(Ops.LOE, NumberPath.class)
            .put(Ops.LOE, ComparablePath.class)
            .put(Ops.LIKE_IC, StringPath.class)
            .build();
    private static final Logger log = LoggerFactory.getLogger(GenericQueryDslFilter.class);
    public static String NULL_FILTER = "NULL";
    public static String NOT_NULL_FILTER = "NOT_NULL";
    /**
     * Custom converter used to convert the filter String value to the respective Value on the QueryDSL/JPA object A custom converter is needed to add the Enum conversion types
     */
    static BeanUtilsBean beanUtilsBean = new BeanUtilsBean(new ConvertUtilsBean() {
        @Override
        public Object convert(Object value, Class clazz) {
            if (clazz.isEnum()) {
                return Enum.valueOf(clazz, value.toString());
            } else {
                return super.convert(value, clazz);
            }
        }

        public Object convert(String value, Class clazz) {
            if (clazz.isEnum()) {
                return Enum.valueOf(clazz, value.toString());
            } else {
                return super.convert(value, clazz);
            }
        }
    });


    /**
     * build the Filter for the base QueryDSL class and the passed in filter list
     *
     * @param filters name:test, age>10
     */
    public static <T> BooleanBuilder buildFilter(EntityPathBase basePath, List<SearchCriteria> filters) {
        BooleanBuilder booleanBuilder = new BooleanBuilder();
        if (filters != null && !filters.isEmpty()) {
            filters.stream().forEach(filter -> {
                Path p = buildPathFromFilterColumn(basePath, filter.getKey());
                if (p != null) {
                    Object value = filter.getValue();
                    Operator op = operators.get(filter.getOperation());
                    if (validateOperatorPath(op, p, filter.getKey(), value)) {
                        if (Ops.LIKE_IC.equals(op) && value instanceof String && value != null && !((String) value).endsWith("%")) {
                            value = ((String) value) + "%";
                        }
                        if (value == null || (value instanceof String && ((String) value).equalsIgnoreCase(NULL_FILTER))) {
                            op = Ops.IS_NULL;
                            value = null;
                        } else if (value instanceof String && ((String) value).equalsIgnoreCase(NOT_NULL_FILTER)) {
                            op = Ops.IS_NOT_NULL;
                            value = null;
                        }

                        if (value != null) {
                            Object convertedValue = getValueForQuery(p, filter.getKey(), op, value);
                            if (convertedValue != null && convertedValue instanceof Collection) {
                                op = Ops.IN;
                            }
                            if (convertedValue != null) {
                                booleanBuilder.and(Expressions.predicate(op, p, Expressions.constant(convertedValue)));
                            }

                        } else {
                            booleanBuilder.and(Expressions.predicate(op, p));
                        }

                    }
                }
            });
        }
        return booleanBuilder;
    }

    /**
     * Build the QueryDSL where filter from the filter string
     *
     * Example filter strings:
     * endTime==NULL  will be  where JOB_EXECUTION.END_TIME IS NULL
     * endTime==NOT_NULL  will be  where JOB_EXECUTION.END_TIME IS NOT NULL
     * jobinstance.name==jobName,jobExcutionId>=200  will be  where JOB_EXECUTION.JOB_INSTANCE.JOB_NAME = 'jobName' AND JOB_EXECUTION.JOB_EXECUTION_ID >=200
     * jobInstance.feed.feedType==FEED,jobInstance.feed.name==movies.new_releases will be JOB_EXECUTION.JOB_INSTANCE.FEED.FEED_TYPE = 'FEED' AND JOB_EXECUTION.JOB_INSTANCE.FEED = 'movies.new_releases'
     * status=="COMPLETED,FAILED" -  in clause.  This is mapped with == and the values surrounded by quotes \"VALUE1,VALUE2\"
     *
     * @param basePath     Example: QJpaBatchJobExecution.jpaBatchJobExecution
     * @param filterString <column><operator><value> Example: jobinstance.name==jobName,jobExcutionId>=200.  that will search for all jobs named 'jobName' and jobExecutionId >= 200
     */
    public static BooleanBuilder buildFilter(EntityPathBase basePath, String filterString) {
        List<SearchCriteria> searchCriterias = parseFilterString(filterString);
        return buildFilter(basePath, searchCriterias);
    }

    /**
     * convert a passed in filter string to a list of criteria objects
     *
     * @param filterString a filter, <column><operator><value> Example: jobinstance.name==jobName,jobExcutionId>=200.  that will search for all jobs named 'jobName' and jobExecutionId >= 200
     * @return a list of criteria objects
     */
    public static List<SearchCriteria> parseFilterString(String filterString) {
        List<SearchCriteria> searchCriterias = new ArrayList<>();
        if (StringUtils.isNotBlank(filterString)) {

            //first match and split on , for various filters
            String[] filterConditions = filterString.split(",(?=(?:[^\\\"]*\\\"[^\\\"]*\\\")*[^\\\"]*$)");
            List<String> filterConditionsList = Arrays.asList(filterConditions);

            //Pattern used to match the <column><operator><value>
            String validOperatorsRegEx = operators.keySet().stream().map(key -> key).collect(Collectors.joining("|"));
            Pattern columnOperatorValuePattern = Pattern.compile("(.*)(" + validOperatorsRegEx + ")(.*)");

            filterConditionsList.stream().forEach(filter -> {
                Matcher matcher = columnOperatorValuePattern.matcher(filter);
                while (matcher.find()) {
                    String field = matcher.group(1);
                    String operator = matcher.group(2);
                    String value = matcher.group(3);
                    searchCriterias.add(new SearchCriteria(field, operator, value));
                }
            });
        }
        return searchCriterias;
    }


    /**
     * Validate the Operator is ok for the passed in Path. If not it will exclude it from the query and log a warning as to why
     *
     * @param operator the operator
     * @param path     the path to the field/column
     * @param column   the column to filter
     * @param value    the value to look for/match
     * @return true if the operator is valid for the given column data type, false if not
     */
    private static boolean validateOperatorPath(Operator operator, Path path, String column, Object value) {
        if (operatorPaths.containsKey(operator)) {
            ImmutableCollection<Class<? extends Path>> validPathClasses = operatorPaths.get(operator);
            boolean valid = validPathClasses.stream().anyMatch(p -> path.getClass().isAssignableFrom(p));
            if (!valid) {
                log.warn(
                    "Filter {} {} {} will not be applied.  Operator {} is not valid for the field {}.  It will not be applied to the query.  The Field {} is a type of {}.  it needs to be a type of {} ",
                    column, operator, value, operator, column, column, path, validPathClasses);
            }
            return valid;
        } else {
            return true;
        }
    }

    private static Long convertDateTimeStringToMillis(String filterKey, Object value) {
        Long millis = null;
        if (CommonFilterTranslations.isDateStoredAsMillisField(filterKey) && value instanceof String) {
            //attempt to convert the incoming value to a long
            try {
                DateTime dateTime = Formatters.parseDateTime((String) value);
                millis = dateTime.getMillis();
            } catch (IllegalArgumentException e) {
                //uanble to parse the value
            }
        }
        return millis;
    }

    /**
     * converts the String filter value to the proper QueryDSL Type based upon the Path provided
     *
     * @param path  the path to the field to query
     * @param op    the operator to use
     * @param value the value to match
     * @return the object value applying a comma separated string to a list of objects for IN clause functions
     */
    private static Object getValueForQuery(Path path, String filterKey, Operator op, Object value) {
        Class type = path.getType();
        Object o = null;
        boolean isDateTimeMillisField = CommonFilterTranslations.isDateStoredAsMillisField(filterKey);

        if (Ops.EQ.equals(op) || Ops.EQ_IGNORE_CASE.equals(op)) {
            // handle the in clause correctly
            if (value instanceof String) {
                String sValue = (String) value;
                //remove the Quotes
                sValue = sValue.replace("\"", "");
                //split on ,
                String[] items = StringUtils.split(sValue, ",");
                //add to List
                List<Object> convertedItems = new ArrayList<>();
                for (String item : items) {
                    try {
                        Object convertedListItem = null;
                        if (isDateTimeMillisField) {
                            convertedListItem = convertDateTimeStringToMillis(filterKey, item);
                        }
                        if (convertedListItem == null) {
                            convertedListItem = beanUtilsBean.getConvertUtils().convert(item, type);
                        }
                        convertedItems.add(convertedListItem);
                    } catch (Exception e) {
                        //handle conversion error
                    }
                }
                o = convertedItems;

            }
        } else {
            if (isDateTimeMillisField) {
                o = convertDateTimeStringToMillis(filterKey, value);
            }
            if (o == null) {
                o = beanUtilsBean.getConvertUtils().convert(value, type);
            }
        }
        return o;
    }

    /**
     * Gets the correct path walking the objects if supplied via dot notation. Example a column value of jobInstance.jobName will return the Path for the jobName If an error occurs it will be logged
     * and not included in the query
     */
    private static Path<?> buildPathFromFilterColumn(EntityPathBase basePath, String column) {
        try {

            String resolvedFilter = CommonFilterTranslations.resolvedFilter(basePath, column);
            return (Path<?>) QueryDslPathInspector.getFieldObject(basePath, resolvedFilter);
        } catch (IllegalAccessException e) {
            log.warn("Unable to add {} to Query filter.  Unable to return the correct Query Path for field: {} on Object: {}, Error: {} ", column, column, basePath, e.getMessage());
        }
        return null;
    }


}
