package com.avaje.ebeaninternal.server.querydefn;

import com.avaje.ebean.ExpressionFactory;
import com.avaje.ebean.FetchConfig;
import com.avaje.ebean.OrderBy;
import com.avaje.ebean.Query;
import com.avaje.ebeaninternal.api.HashQueryPlanBuilder;
import com.avaje.ebeaninternal.api.SpiExpression;
import com.avaje.ebeaninternal.api.SpiExpressionFactory;
import com.avaje.ebeaninternal.api.SpiExpressionList;
import com.avaje.ebeaninternal.api.SpiQuery;
import com.avaje.ebeaninternal.server.expression.FilterExprPath;
import com.avaje.ebeaninternal.server.expression.FilterExpressionList;
import com.avaje.ebeaninternal.server.expression.Same;
import com.avaje.ebeaninternal.server.query.SplitName;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;

/**
 * Represents the Properties of an Object Relational query.
 */
public class OrmQueryProperties implements Serializable {

  private static final long serialVersionUID = -8785582703966455658L;

  protected static final FetchConfig DEFAULT_FETCH = new FetchConfig();

  private final String parentPath;
  private final String path;

  private final String rawProperties;
  private final String trimmedProperties;

  private final LinkedHashSet<String> included;

  private final FetchConfig fetchConfig;

  /**
   * Flag set when this fetch path needs to be a query join.
   */
  private boolean markForQueryJoin;

  private boolean cache;

  private boolean readOnly;

  /**
   * Included bean joins.
   */
  private Set<String> includedBeanJoin;

  /**
   * Add these properties to the select so that the foreign key columns are included in the query.
   */
  private Set<String> secondaryQueryJoins;

  private List<OrmQueryProperties> secondaryChildren;

  /**
   * OrderBy properties that where on the main query but moved here as they relate to this (query join).
   */
  @SuppressWarnings("rawtypes")
  private OrderBy orderBy;

  /**
   * A filter that can be applied to the fetch of this path in the object graph.
   */
  @SuppressWarnings("rawtypes")
  private SpiExpressionList filterMany;

  /**
   * Construct for root so path (and parentPath) are null.
   */
  public OrmQueryProperties() {
    this((String)null);
  }

  /**
   * Construct with a given path.
   */
  public OrmQueryProperties(String path) {
    this.path = path;
    this.parentPath = SplitName.parent(path);
    this.rawProperties = null;
    this.trimmedProperties = null;
    this.included = null;
    this.fetchConfig = DEFAULT_FETCH;
  }

  public OrmQueryProperties(String path, String rawProperties) {
    this(path, rawProperties, null);
  }

  public OrmQueryProperties(String path, String rawProperties, FetchConfig fetchConfig) {

    OrmQueryPropertiesParser.Response response = OrmQueryPropertiesParser.parse(rawProperties);

    this.path = path;
    this.parentPath = SplitName.parent(path);
    this.rawProperties = rawProperties;
    this.trimmedProperties = response.properties;
    this.included = response.included;
    this.cache = response.cache;
    this.readOnly = response.readOnly;
    if (fetchConfig != null) {
      this.fetchConfig = fetchConfig;
    } else {
      this.fetchConfig = response.fetchConfig;
    }
  }

  public OrmQueryProperties(String path, LinkedHashSet<String> parsedProperties) {
    if (parsedProperties == null) {
      throw new IllegalArgumentException("parsedProperties is null");
    }

    this.path = path;
    this.parentPath = SplitName.parent(path);
    // for rawSql parsedProperties can be empty (when only fetching Id property)
    this.included = parsedProperties;
    this.rawProperties =  join(parsedProperties);
    this.trimmedProperties = rawProperties;
    this.cache = false;
    this.readOnly = false;
    this.fetchConfig = DEFAULT_FETCH;
  }

  /**
   * Join the set of properties into a comma delimited string.
   */
  private String join(LinkedHashSet<String> parsedProperties) {
    StringBuilder sb = new StringBuilder(50);
    boolean first = true;
    for (String property : parsedProperties) {
      if (first) {
        first = false;
      } else {
        sb.append(",");
      }
      sb.append(property);
    }
    return sb.toString();
  }

  /**
   * Copy constructor.
   */
  private OrmQueryProperties(OrmQueryProperties source) {

    this.parentPath = source.parentPath;
    this.path = source.path;
    this.rawProperties = source.rawProperties;
    this.trimmedProperties = source.trimmedProperties;
    this.cache = source.cache;
    this.readOnly = source.readOnly;
    this.fetchConfig = source.fetchConfig;
    this.filterMany = source.filterMany;
    this.included = (source.included == null) ? null : new LinkedHashSet<String>(source.included);
    if (includedBeanJoin != null) {
      this.includedBeanJoin = new HashSet<String>(source.includedBeanJoin);
    }
  }

  /**
   * Creates a copy of the OrmQueryProperties.
   */
  public OrmQueryProperties copy() {
    return new OrmQueryProperties(this);
  }

  /**
   * Move a OrderBy.Property from the main query to this query join.
   */
  @SuppressWarnings("rawtypes")
  public void addSecJoinOrderProperty(OrderBy.Property orderProp) {
    if (orderBy == null) {
      orderBy = new OrderBy();
    }
    orderBy.add(orderProp);
  }

  public FetchConfig getFetchConfig() {
    return fetchConfig;
  }

  /**
   * Return the expressions used to filter on this path. This should be a many path to use this
   * method.
   */
  @SuppressWarnings({"rawtypes", "unchecked"})
  public <T> SpiExpressionList<T> filterMany(Query<T> rootQuery) {
    if (filterMany == null) {
      FilterExprPath exprPath = new FilterExprPath(path);
      SpiExpressionFactory queryEf = (SpiExpressionFactory) rootQuery.getExpressionFactory();
      ExpressionFactory filterEf = queryEf.createExpressionFactory();// exprPath);
      filterMany = new FilterExpressionList(exprPath, filterEf, rootQuery);
      // by default we need to make this a 'query join' now
      markForQueryJoin = true;
    }
    return filterMany;
  }

  /**
   * Return the filterMany expression list (can be null).
   */
  public SpiExpressionList<?> getFilterManyTrimPath(int trimPath) {
    if (filterMany == null) {
      return null;
    }
    return filterMany.trimPath(trimPath);
  }

  /**
   * Return the filterMany expression list (can be null).
   */
  public SpiExpressionList<?> getFilterMany() {
    return filterMany;
  }

  /**
   * Set the filterMany expression list.
   */
  public void setFilterMany(SpiExpressionList<?> filterMany) {
    this.filterMany = filterMany;
    this.markForQueryJoin = true;
  }

  /**
   * Define the select and joins for this query.
   */
  @SuppressWarnings("unchecked")
  public void configureBeanQuery(SpiQuery<?> query) {

    if (trimmedProperties != null && !trimmedProperties.isEmpty()) {
      query.select(trimmedProperties);
    }

    if (filterMany != null) {
      SpiExpressionList<?> trimPath = filterMany.trimPath(path.length() + 1);
      List<SpiExpression> underlyingList = trimPath.getUnderlyingList();
      for (SpiExpression spiExpression : underlyingList) {
        query.where().add(spiExpression);
      }
    }

    if (secondaryChildren != null) {
      int trimPath = path.length() + 1;
      for (int i = 0; i < secondaryChildren.size(); i++) {
        OrmQueryProperties p = secondaryChildren.get(i);
        String path = p.getPath();
        path = path.substring(trimPath);
        query.fetch(path, p.getProperties(), p.getFetchConfig());
        query.setFilterMany(path, p.getFilterManyTrimPath(trimPath));
      }
    }

    if (orderBy != null) {
      query.setOrder(orderBy.copyWithTrim(path));
    }
  }

  public boolean hasSelectClause() {
    if ("*".equals(trimmedProperties)) {
      // explicitly selected all properties
      return true;
    }
    // explicitly selected some properties
    return included != null || filterMany != null;
  }

  /**
   * Return true if the properties and configuration are empty.
   */
  public boolean isEmpty() {
    return rawProperties == null || rawProperties.isEmpty();
  }

  public String toString() {
    StringBuilder sb = new StringBuilder(40);
    append("", sb);
    return sb.toString();
  }

  public String append(String prefix, StringBuilder sb) {
    sb.append(prefix);
    if (path != null) {
      sb.append(path).append(" ");
    }
    if (!isEmpty()) {
      sb.append("(").append(rawProperties).append(") ");
    }
    return sb.toString();
  }

  public boolean isChild(OrmQueryProperties possibleChild) {
    return possibleChild.getPath().startsWith(path + ".");
  }

  /**
   * For secondary queries add a child element.
   */
  public void add(OrmQueryProperties child) {
    if (secondaryChildren == null) {
      secondaryChildren = new ArrayList<OrmQueryProperties>();
    }
    secondaryChildren.add(child);
  }

  /**
   * Return the raw properties.
   */
  public String getProperties() {
    return rawProperties;
  }

  /**
   * Return true if this includes all properties on the path.
   */
  public boolean allProperties() {
    return included == null;
  }

  /**
   * Return true if this property is included as a bean join.
   * <p>
   * If a property is included as a bean join then it should not be included as a reference/proxy to
   * avoid duplication.
   * </p>
   */
  public boolean isIncludedBeanJoin(String propertyName) {
    return includedBeanJoin != null && includedBeanJoin.contains(propertyName);
  }

  /**
   * Add a bean join property.
   */
  public void includeBeanJoin(String propertyName) {
    if (includedBeanJoin == null) {
      includedBeanJoin = new HashSet<String>();
    }
    includedBeanJoin.add(propertyName);
  }

  /**
   * This excludes the bean joined properties.
   * <p>
   * This is because bean joins will have there own node in the SqlTree.
   * </p>
   */
  public Set<String> getSelectProperties() {

    if (secondaryQueryJoins == null) {
      return included;
    }

    LinkedHashSet<String> temp = new LinkedHashSet<String>(2 * (secondaryQueryJoins.size() + included.size()));
    temp.addAll(included);
    temp.addAll(secondaryQueryJoins);
    return temp;
  }

  public void addSecondaryQueryJoin(String property) {
    if (secondaryQueryJoins == null) {
      secondaryQueryJoins = new HashSet<String>(4);
    }
    secondaryQueryJoins.add(property);
  }

  /**
   * Return the property set.
   */
  public Set<String> getIncluded() {
    return included;
  }

  public boolean isIncluded(String propName) {

    if (includedBeanJoin != null && includedBeanJoin.contains(propName)) {
      return false;
    }
    // all properties included
    return included == null || included.contains(propName);
  }

  /**
   * Mark this path as needing to be a query join.
   */
  public void markForQueryJoin() {
    markForQueryJoin = true;
  }

  /**
   * Return true if this path is a 'query join'.
   */
  public boolean isQueryFetch() {
    return markForQueryJoin || getQueryFetchBatch() > -1;
  }

  /**
   * Return true if this path is a 'fetch join'.
   */
  public boolean isFetchJoin() {
    return !isQueryFetch() && !isLazyFetch();
  }

  /**
   * Return true if this path is a lazy fetch.
   */
  public boolean isLazyFetch() {
    return getLazyFetchBatch() > -1;
  }

  /**
   * Return the batch size to use for the query join.
   */
  public int getQueryFetchBatch() {
    return fetchConfig.getQueryBatchSize();
  }

  /**
   * Return true if a query join should eagerly fetch 'all' rather than the 'first'.
   */
  public boolean isQueryFetchAll() {
    return fetchConfig.isQueryAll();
  }

  /**
   * Return the batch size to use for lazy loading.
   */
  public int getLazyFetchBatch() {
    return fetchConfig.getLazyBatchSize();
  }

  /**
   * Return true if this path has the +readonly option.
   */
  public boolean isReadOnly() {
    return readOnly;
  }

  /**
   * Return true if this path has the +cache option to hit the cache.
   */
  public boolean isCache() {
    return cache;
  }

  /**
   * Return the parent path.
   */
  public String getParentPath() {
    return parentPath;
  }

  /**
   * Return the path relative to the root of the graph.
   */
  public String getPath() {
    return path;
  }

  /**
   * Return true if the properties are the same for autoTune purposes.
   */
  public boolean isSameByAutoTune(OrmQueryProperties p2) {
    if (included == null) {
      return p2 == null || p2.included == null;
    } else if (p2 == null) {
      return false;
    }
    return included.equals(p2.included);
  }

  /**
   * Properties are the same for query plan purposes.
   */
  public boolean isSameByPlan(OrmQueryProperties p2) {

    if (!Same.sameByValue(secondaryQueryJoins, p2.secondaryQueryJoins)) return false;
    if (!Same.sameByValue(included, p2.included)) return false;
    if (!Same.sameByNull(filterMany, p2.filterMany)) return false;
    if (filterMany != null && !filterMany.isSameByPlan(p2.filterMany)) return false;

    return fetchConfig.equals(p2.fetchConfig);
  }

  /**
   * Calculate the query plan hash.
   */
  public void queryPlanHash(HashQueryPlanBuilder builder) {

    builder.add(path);
    builder.addOrdered(included);
    builder.add(secondaryQueryJoins);

    builder.add(filterMany != null);
    if (filterMany != null) {
      filterMany.queryPlanHash(builder);
    }
    builder.add(fetchConfig.hashCode());
  }

}
