/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

this.EXPORTED_SYMBOLS = ["GlodaQueryClassFactory"];

var Cc = Components.classes;
var Ci = Components.interfaces;
var Cr = Components.results;
var Cu = Components.utils;

Cu.import("resource:///modules/gloda/log4moz.js");

// GlodaDatastore has some constants we need, and oddly enough, there was no
//  load dependency preventing us from doing this.
Cu.import("resource:///modules/gloda/datastore.js");

/**
 * @class Query class core; each noun gets its own sub-class where attributes
 *  have helper methods bound.
 *
 * @param aOptions A dictionary of options.  Current legal options are:
 *     - noMagic: Indicates that the noun's dbQueryJoinMagic should be ignored.
 *                Currently, this means that messages will not have their
 *                full-text indexed values re-attached.  This is planned to be
 *                offset by having queries/cache lookups that do not request
 *                noMagic to ensure that their data does get loaded.
 *     - explicitSQL: A hand-rolled alternate representation for the core
 *           SELECT portion of the SQL query.  The queryFromQuery logic still
 *           generates its normal query, we just ignore its result in favor of
 *           your provided value.  This means that the positional parameter
 *           list is still built and you should/must rely on those bound
 *           parameters (using '?').  The replacement occurs prior to the
 *           outerWrapColumns, ORDER BY, and LIMIT contributions to the query.
 *     - outerWrapColumns: If provided, wraps the query in a "SELECT *,blah
 *           FROM (actual query)" where blah is your list of outerWrapColumns
 *           made comma-delimited.  The idea is that this allows you to
 *           reference the result of expressions inside the query using their
 *           names rather than having to duplicate the logic.  In practice,
 *           this makes things more readable but is unlikely to improve
 *           performance.  (Namely, my use of 'offsets' for full-text stuff
 *           ends up in the EXPLAIN plan twice despite this.)
 *     - noDbQueryValidityConstraints: Indicates that any validity constraints
 *           should be ignored. This should be used when you need to get every
 *           match regardless of whether it's valid.
 *
 * @property _owner The query instance that holds the list of unions...
 * @property _constraints A list of (lists of OR constraints) that are ANDed
 *     together.  For example [[FROM bob, FROM jim], [DATE last week]] would
 *     be requesting us to find all the messages from either bob or jim, and
 *     sent in the last week.
 * @property _unions A list of other queries whose results are unioned with our
 *     own.  There is no concept of nesting or sub-queries apart from this
 *     mechanism.
 */
function GlodaQueryClass(aOptions) {
  this.options = (aOptions != null) ? aOptions : {};

  // if we are an 'or' clause, who is our parent whom other 'or' clauses should
  //  spawn from...
  this._owner = null;
  // our personal chain of and-ing.
  this._constraints = [];
  // the other instances we union with
  this._unions = [];

  this._order = [];
  this._limit = 0;
}

GlodaQueryClass.prototype = {
  WILDCARD: {},

  get constraintCount() {
    return this._constraints.length;
  },

  or: function gloda_query_or() {
    let owner = this._owner || this;
    let orQuery = new this._queryClass();
    orQuery._owner = owner;
    owner._unions.push(orQuery);
    return orQuery;
  },

  orderBy: function gloda_query_orderBy() {
    for (let iArg = 0; iArg < arguments.length; iArg++) {
      let arg = arguments[iArg];
      this._order.push(arg);
    }
    return this;
  },

  limit: function gloda_query_limit(aLimit) {
    this._limit = aLimit;
    return this;
  },

  /**
   * Return a collection asynchronously populated by this collection.  You must
   *  provide a listener to receive notifications from the collection as it
   *  receives updates.  The listener object should implement onItemsAdded,
   *  onItemsModified, and onItemsRemoved methods, all of which take a single
   *  argument which is the list of items which have been added, modified, or
   *  removed respectively.
   *
   * @param aListener The collection listener.
   * @param [aData] The data attribute to set on the collection.
   * @param [aArgs.becomeExplicit] Make the collection explicit so that the
   *     collection will only ever contain results found from the database
   *     query and the query will not be updated as new items are indexed that
   *     also match the query.
   * @param [aArgs.becomeNull] Change the collection's query to a null query so
   *     that it will never receive any additional added/modified/removed events
   *     apart from the underlying database query.  This is really only intended
   *     for gloda internal use but may be acceptable for non-gloda use.  Please
   *     ask on mozilla.dev.apps.thunderbird first to make sure there isn't a
   *     better solution for your use-case.  (Note: removals will still happen
   *     when things get fully deleted.)
   */
  getCollection: function gloda_query_getCollection(aListener, aData, aArgs) {
    this.completed = false;
    return this._nounDef.datastore.queryFromQuery(this, aListener, aData,
             /* aExistingCollection */ null, /* aMasterCollection */ null,
             aArgs);
  },

  /**
   * Test whether the given first-class noun instance satisfies this query.
   *
   * @testpoint gloda.query.test
   */
  test: function gloda_query_test(aObj) {
    // when changing this method, be sure that GlodaDatastore's queryFromQuery
    //  method likewise has any required changes made.
    let unionQueries = [this].concat(this._unions);

    for (let iUnion = 0; iUnion < unionQueries.length; iUnion++) {
      let curQuery = unionQueries[iUnion];

      // assume success until a specific (or) constraint proves us wrong
      let querySatisfied = true;
      for (let iConstraint = 0; iConstraint < curQuery._constraints.length;
           iConstraint++) {
        let constraint = curQuery._constraints[iConstraint];
        let [constraintType, attrDef] = constraint;
        let boundName = attrDef ? attrDef.boundName : "id";
        if ((boundName in aObj) &&
            aObj[boundName] === GlodaDatastore.IGNORE_FACET) {
          querySatisfied = false;
          break;
        }

        let constraintValues = constraint.slice(2);

        if (constraintType === GlodaDatastore.kConstraintIdIn) {
          if (constraintValues.indexOf(aObj.id) == -1) {
            querySatisfied = false;
            break;
          }
        }
        // @testpoint gloda.query.test.kConstraintIn
        else if ((constraintType === GlodaDatastore.kConstraintIn) ||
                 (constraintType === GlodaDatastore.kConstraintEquals)) {
          let objectNounDef = attrDef.objectNounDef;

          // if they provide an equals comparator, use that.
          // (note: the next case has better optimization possibilities than
          //  this mechanism, but of course has higher initialization costs or
          //  code complexity costs...)
          if (objectNounDef.equals) {
            let testValues;
            if (!(boundName in aObj))
              testValues = [];
            else if (attrDef.singular)
              testValues = [aObj[boundName]];
            else
              testValues = aObj[boundName];

            // If there are no constraints, then we are just testing for there
            //  being a value.  Succeed (continue) in that case.
            if (constraintValues.length == 0 && testValues.length &&
                testValues[0] != null)
              continue;

            // If there are no test values and the empty set is significant,
            //  then check if any of the constraint values are null (our
            //  empty indicator.)
            if (testValues.length == 0 && attrDef.emptySetIsSignificant) {
              let foundEmptySetSignifier = false;
              for (let constraintValue of constraintValues) {
                if (constraintValue == null) {
                  foundEmptySetSignifier = true;
                  break;
                }
              }
              if (foundEmptySetSignifier)
                continue;
            }

            let foundMatch = false;
            for (let testValue of testValues) {
              for (let value of constraintValues) {
                if (objectNounDef.equals(testValue, value)) {
                  foundMatch = true;
                  break;
                }
              }
              if (foundMatch)
                break;
            }
            if (!foundMatch) {
              querySatisfied = false;
              break;
            }
          }
          // otherwise, we need to convert everyone to their param/value form
          //  in order to test for equality
          else {
            // let's just do the simple, obvious thing for now.  which is
            //  what we did in the prior case but exploding values using
            //  toParamAndValue, and then comparing.
            let testValues;
            if (!(boundName in aObj))
              testValues = [];
            else if (attrDef.singular)
              testValues = [aObj[boundName]];
            else
              testValues = aObj[boundName];

            // If there are no constraints, then we are just testing for there
            //  being a value.  Succeed (continue) in that case.
            if (constraintValues.length == 0 && testValues.length &&
                testValues[0] != null)
              continue;
            // If there are no test values and the empty set is significant,
            //  then check if any of the constraint values are null (our
            //  empty indicator.)
            if (testValues.length == 0 && attrDef.emptySetIsSignificant) {
              let foundEmptySetSignifier = false;
              for (let constraintValue of constraintValues) {
                if (constraintValue == null) {
                  foundEmptySetSignifier = true;
                  break;
                }
              }
              if (foundEmptySetSignifier)
                continue;
            }

            let foundMatch = false;
            for (let testValue of testValues) {
              let [aParam, aValue] = objectNounDef.toParamAndValue(testValue);
              for (let value of constraintValues) {
                // skip empty set check sentinel values
                if (value == null && attrDef.emptySetIsSignificant)
                  continue;
                let [bParam, bValue] = objectNounDef.toParamAndValue(value);
                if (aParam == bParam && aValue == bValue) {
                  foundMatch = true;
                  break;
                }
              }
              if (foundMatch)
                break;
            }
            if (!foundMatch) {
              querySatisfied = false;
              break;
            }
          }
        }
        // @testpoint gloda.query.test.kConstraintRanges
        else if (constraintType === GlodaDatastore.kConstraintRanges) {
          let objectNounDef = attrDef.objectNounDef;

          let testValues;
          if (!(boundName in aObj))
              testValues = [];
          else if (attrDef.singular)
            testValues = [aObj[boundName]];
          else
            testValues = aObj[boundName];

          let foundMatch = false;
          for (let testValue of testValues) {
            let [tParam, tValue] = objectNounDef.toParamAndValue(testValue);
            for (let rangeTuple of constraintValues) {
              let [lowerRValue, upperRValue] = rangeTuple;
              if (lowerRValue == null) {
                let [upperParam, upperValue] =
                  objectNounDef.toParamAndValue(upperRValue);
                if (tParam == upperParam && tValue <= upperValue) {
                  foundMatch = true;
                  break;
                }
              }
              else if (upperRValue == null) {
                let [lowerParam, lowerValue] =
                  objectNounDef.toParamAndValue(lowerRValue);
                if (tParam == lowerParam && tValue >= lowerValue) {
                  foundMatch = true;
                  break;
                }
              }
              else { // no one is null
                let [upperParam, upperValue] =
                  objectNounDef.toParamAndValue(upperRValue);
                let [lowerParam, lowerValue] =
                  objectNounDef.toParamAndValue(lowerRValue);
                if ((tParam == lowerParam) && (tValue >= lowerValue) &&
                    (tParam == upperParam) && (tValue <= upperValue)) {
                  foundMatch = true;
                  break;
                }
              }
            }
            if (foundMatch)
              break;
          }
          if (!foundMatch) {
            querySatisfied = false;
            break;
          }
        }
        // @testpoint gloda.query.test.kConstraintStringLike
        else if (constraintType === GlodaDatastore.kConstraintStringLike) {
          let curIndex = 0;
          let value = (boundName in aObj) ? aObj[boundName] : "";
          // the attribute must be singular, we don't support arrays of strings.
          for (let valuePart of constraintValues) {
            if (typeof valuePart == "string") {
              let index = value.indexOf(valuePart);
              // if curIndex is null, we just need any match
              // if it's not null, it must match the offset of our found match
              if (curIndex === null) {
                if (index == -1)
                  querySatisfied = false;
                else
                  curIndex = index + valuePart.length;
              }
              else {
                if (index != curIndex)
                  querySatisfied = false;
                else
                  curIndex = index + valuePart.length;
              }
              if (!querySatisfied)
                break;
            }
            else // wild!
              curIndex = null;
          }
          // curIndex must be null or equal to the length of the string
          if (querySatisfied && curIndex !== null && curIndex != value.length)
            querySatisfied = false;
        }
        // @testpoint gloda.query.test.kConstraintFulltext
        else if (constraintType === GlodaDatastore.kConstraintFulltext) {
          // this is beyond our powers. Even if we have the fulltext content in
          //  memory, which we may not, the tokenization and such to perform
          //  the testing gets very complicated in the face of i18n, etc.
          // so, let's fail if the item is not already in the collection, and
          //  let the testing continue if it is.  (some other constraint may no
          //  longer apply...)
          if (!(aObj.id in this.collection._idMap))
            querySatisfied = false;
        }

        if (!querySatisfied)
          break;
      }

      if (querySatisfied)
        return true;
    }
    return false;
  },

  /**
   * Helper code for noun definitions of queryHelpers that want to build a
   *  traditional in/equals constraint.  The goal is to let them build a range
   *  without having to know how we structure |_constraints|.
   *
   * @protected
   */
  _inConstraintHelper:
      function gloda_query__discreteConstraintHelper(aAttrDef, aValues) {
    let constraint =
      [GlodaDatastore.kConstraintIn, aAttrDef].concat(aValues);
    this._constraints.push(constraint);
    return this;
  },

  /**
   * Helper code for noun definitions of queryHelpers that want to build a
   *  range.  The goal is to let them build a range without having to know how
   *  we structure |_constraints| or requiring them to mark themselves as
   *  continuous to get a "Range".
   *
   * @protected
   */
  _rangedConstraintHelper:
      function gloda_query__rangedConstraintHelper(aAttrDef, aRanges) {
    let constraint =
      [GlodaDatastore.kConstraintRanges, aAttrDef].concat(aRanges);
    this._constraints.push(constraint);
    return this;
  }
};

/**
 * @class A query that never matches anything.
 *
 * Collections corresponding to this query are intentionally frozen in time and
 *  do not want to be notified of any updates.  We need the collection to be
 *  registered with the collection manager so that the noun instances in the
 *  collection are always 'reachable' via the collection for as long as we might
 *  be handing out references to the instances.  (The other way to avoid updates
 *  would be to not register the collection, but then items might not be
 *  reachable.)
 * This is intended to be used in implementation details behind the gloda
 *  abstraction barrier.  For example, the message indexer likes to be able
 *  to represent 'ghost' and deleted messages, but these should never be exposed
 *  to the user.  For code simplicity, it wants to be able to use the query
 *  mechanism.  But it doesn't want updates that are effectively
 *  nonsensical.  For example, a ghost message that is reused by message
 *  indexing may already be present in a collection; when the collection manager
 *  receives an itemsAdded event, a GlodaExplicitQueryClass would result in
 *  an item added notification in that case, which would wildly not be desired.
 */
function GlodaNullQueryClass() {
}

GlodaNullQueryClass.prototype = {
  /**
   * No options; they are currently only needed for SQL query generation, which
   *  does not happen for null queries.
   */
  options: {},

  /**
   * Provide a duck-typing way of indicating to GlodaCollectionManager that our
   *  associated collection just doesn't want anything to change.  Our test
   *  function is able to convey most of it, but special-casing has to happen
   *  somewhere, so it happens here.
   */
  frozen: true,

  /**
   * Since our query never matches anything, it doesn't make sense to let
   *  someone attempt to construct a boolean OR involving us.
   *
   * @returns null
   */
  or: function() {
    return null;
  },

  /**
   * Return nothing (null) because it does not make sense to create a collection
   *  based on a null query.  This method is normally used (on a normal query)
   *  to return a collection populated by the constraints of the query.  We
   *  match nothing, so we should return nothing.  More importantly, you are
   *  currently doing something wrong if you try and do this, so null is
   *  appropriate.  It may turn out that it makes sense for us to return an
   *  empty collection in the future for sentinel value purposes, but we'll
   *  cross that bridge when we come to it.
   *
   * @returns null
   */
  getCollection: function() {
    return null;
  },

  /**
   * Never matches anything.
   *
   * @param aObj The object someone wants us to test for relevance to our
   *     associated collection.  But we don't care!  Not a fig!
   * @returns false
   */
  test: function gloda_query_null_test(aObj) {
    return false;
  }
};

/**
 * @class A query that only 'tests' for already belonging to the collection.
 *
 * This type of collection is useful for when you (or rather your listener)
 *  are interested in hearing about modifications to your collection or removals
 *  from your collection because of deletion, but do not want to be notified
 *  about newly indexed items matching your normal query constraints.
 *
 * @param aCollection The collection this query belongs to.  This needs to be
 *     passed-in here or the collection should set the attribute directly when
 *     the query is passed in to a collection's constructor.
 */
function GlodaExplicitQueryClass(aCollection) {
  this.collection = aCollection;
}

GlodaExplicitQueryClass.prototype = {
  /**
   * No options; they are currently only needed for SQL query generation, which
   *  does not happen for explicit queries.
   */
  options: {},

  /**
   * Since our query is intended to only match the contents of our collection,
   *  it doesn't make sense to let someone attempt to construct a boolean OR
   *  involving us.
   *
   * @returns null
   */
  or: function() {
    return null;
  },

  /**
   * Return nothing (null) because it does not make sense to create a collection
   *  based on an explicit query.  This method is normally used (on a normal
   *  query) to return a collection populated by the constraints of the query.
   *  In the case of an explicit query, we expect it will be associated with
   *  either a hand-created collection or the results of a normal query that is
   *  immediately converted into an explicit query.  In all likelihood, calling
   *  this method on an instance of this type is an error, so it is helpful to
   *  return null because people will error hard.
   *
   * @returns null
   */
  getCollection: function() {
    return null;
  },

  /**
   * Matches only items that are already in the collection associated with this
   *  query (by id).
   *
   * @param aObj The object/item to test for already being in the associated
   *     collection.
   * @returns true when the object is in the associated collection, otherwise
   *     false.
   */
  test: function gloda_query_explicit_test(aObj) {
    return (aObj.id in this.collection._idMap);
  }
};

/**
 * @class A query that 'tests' true for everything.  Intended for debugging purposes
 *  only.
 */
function GlodaWildcardQueryClass() {
}

GlodaWildcardQueryClass.prototype = {
  /**
   * No options; they are currently only needed for SQL query generation.
   */
  options: {},

  // don't let people try and mess with us
  or: function() { return null; },
  // don't let people try and query on us (until we have a real use case for
  //  that...)
  getCollection: function() { return null; },
  /**
   * Everybody wins!
   */
  test: function gloda_query_explicit_test(aObj) {
    return true;
  }
};

/**
 * Factory method to effectively create per-noun subclasses of GlodaQueryClass,
 *  GlodaNullQueryClass, GlodaExplicitQueryClass, and GlodaWildcardQueryClass.
 *  For GlodaQueryClass this allows us to add per-noun helpers.  For the others,
 *  this is merely a means of allowing us to attach the (per-noun) nounDef to
 *  the 'class'.
 */
function GlodaQueryClassFactory(aNounDef) {
  let newQueryClass = function(aOptions) {
    GlodaQueryClass.call(this, aOptions);
  };
  newQueryClass.prototype = new GlodaQueryClass();
  newQueryClass.prototype._queryClass = newQueryClass;
  newQueryClass.prototype._nounDef = aNounDef;

  let newNullClass = function(aCollection) {
    GlodaNullQueryClass.call(this);
    this.collection = aCollection;
  };
  newNullClass.prototype = new GlodaNullQueryClass();
  newNullClass.prototype._queryClass = newNullClass;
  newNullClass.prototype._nounDef = aNounDef;

  let newExplicitClass = function(aCollection) {
    GlodaExplicitQueryClass.call(this);
    this.collection = aCollection;
  };
  newExplicitClass.prototype = new GlodaExplicitQueryClass();
  newExplicitClass.prototype._queryClass = newExplicitClass;
  newExplicitClass.prototype._nounDef = aNounDef;

  let newWildcardClass = function(aCollection) {
    GlodaWildcardQueryClass.call(this);
    this.collection = aCollection;
  };
  newWildcardClass.prototype = new GlodaWildcardQueryClass();
  newWildcardClass.prototype._queryClass = newWildcardClass;
  newWildcardClass.prototype._nounDef = aNounDef;

  return [newQueryClass, newNullClass, newExplicitClass, newWildcardClass];
}
