/** @namespace */
var es = {

    /////////////////////////////////////////////////////
    // fixed properties, like special characters, etc

    // The reserved characters in elasticsearch query strings
    // Note that the "\" has to go first, as when these are substituted, that character
    // will get introduced as an escape character
    specialChars : ["\\", "+", "-", "=", "&&", "||", ">", "<", "!", "(", ")", "{", "}", "[", "]", "^", '"', "~", "*", "?", ":", "/"],

    // FIXME: specialChars is not currently used for encoding, but it would be worthwhile giving the option
    // to allow/disallow specific values, but that requires a much better (automated) understanding of the
    // query DSL

    // the reserved special character set with * and " removed, so that users can do quote searches and wildcards
    // if they want
    specialCharsSubSet : ["\\", "+", "-", "=", "&&", "||", ">", "<", "!", "(", ")", "{", "}", "[", "]", "^", "~", "?", ":", "/"],

    // values that have to be in even numbers in the query or they will be escaped
    characterPairs : ['"'],

    // distance units allowed by ES
    distanceUnits : ["km", "mi", "miles", "in", "inch", "yd", "yards", "kilometers", "mm", "millimeters", "cm", "centimeters", "m", "meters"],

    // request method to be used throughout.  Set this before using the module if you want it different
    requestMethod : "get",

    ////////////////////////////////////////////////////

    ////////////////////////////////////////////////////
    // object factories

    aggregationFactory : function(type, params) {
        var constructors = {
            terms: es.newTermsAggregation,
            range: es.newRangeAggregation,
            geo_distance: es.newGeoDistanceAggregation,
            date_histogram: es.newDateHistogramAggregation,
            stats: es.newStatsAggregation,
            cardinality: es.newCardinalityAggregation
        };

        if (constructors[type]) {
            return constructors[type](params);
        }

    },

    filterFactory : function(type, params) {
        var constructors = {
            query_string: es.newQueryString,
            term: es.newTermFilter,
            terms: es.newTermsFilter,
            range: es.newRangeFilter,
            geo_distance_range: es.newGeoDistanceRangeFilter
        };

        if (constructors[type]) {
            return constructors[type](params);
        }
    },

    ////////////////////////////////////////////////////
    /** @namespace */
    /** Query objects for standard query structure */
    /**
     *
     * @param filtered {Boolean} Is this an ES filtered query?
     * @param size=10 {Number} What amount of results are required. ES defaults to 10.
     * @param from {Number} Beginning point for results.
     * @param fields {String[]} Required fields.
     * @param aggs {String[]} ES aggregations.
     * @param must {String[]} ES must query.
     * @param source {String} ES source.
     * @param should {String[]} ES should.
     * @param mustNot {String[]} ES must not.
     * @param partialFields ???
     * @param scriptFields ???
     * @param minimumShouldMatch ???
     * @param facets {String[]} for older versions of ES
     */

    newQuery : function(params) {
        if (!params) { params = {} }
        return new es.Query(params);
    },
    /** @class */
    Query : function(params) {
        // properties that can be set directly (thought note that they may need to be read via their getters)
        this.filtered = false;  // this is no longer present in es5.x
        this.size = params.size !== undefined ? params.size : false;
        this.from = params.from || false;
        this.fields = params.fields || [];
        this.aggs = params.aggs || [];
        this.must = params.must || [];
        this.mustNot = params.mustNot || [];
        this.trackTotalHits = true;   // FIXME: hard code this for the moment, we can introduce the ability to vary it later

        // defaults from properties that will be set through their setters (see the bottom
        // of the function)
        this.queryString = false;
        this.sort = [];

        // ones that we haven't used yet, so are awaiting implementation
        // NOTE: once we implement these, they also need to be considered in merge()
        this.source = params.source || false;
        this.should = params.should || [];
        this.partialFields = params.partialFields || false;
        this.scriptFields = params.scriptFields || false;
        this.minimumShouldMatch = params.minimumShouldMatch || false;
        this.partialFields = params.partialFields || false;
        this.scriptFields = params.scriptFields || false;

        // for old versions of ES, so are not necessarily going to be implemented
        this.facets = params.facets || [];

        this.getSize = function() {
            if (this.size !== undefined && this.size !== false) {
                return this.size;
            }
            return 10;
        };
        this.getFrom = function() {
            if (this.from) {
                return this.from
            }
            return 0;
        };
        this.addField = function(field) {
            if ($.inArray(field, this.fields) === -1) {
                this.fields.push(field);
            }
        };

        this.setQueryString = function(params) {
            var qs = params;
            if (!(params instanceof es.QueryString)) {
                if ($.isPlainObject(params)) {
                    qs = es.newQueryString(params);
                } else {
                    qs = es.newQueryString({queryString: params});
                }
            }
            this.queryString = qs;
        };
        this.getQueryString = function() {
            return this.queryString;
        };
        this.removeQueryString = function() {
            this.queryString = false;
        };

        this.setSortBy = function(params) {
            // overwrite anything that was there before
            this.sort = [];
            // ensure we have a list of sort options
            var sorts = params;
            if (!$.isArray(params)) {
                sorts = [params]
            }
            // add each one
            for (var i = 0; i < sorts.length; i++) {
                this.addSortBy(sorts[i]);
            }
        };
        this.addSortBy = function(params) {
            // ensure we have an instance of es.Sort
            var sort = params;
            if (!(params instanceof es.Sort)) {
                sort = es.newSort(params);
            }
            // prevent repeated sort options being added
            for (var i = 0; i < this.sort.length; i++) {
                var so = this.sort[i];
                if (so.field === sort.field) {
                    return;
                }
            }
            // add the sort option
            this.sort.push(sort);
        };
        this.prependSortBy = function(params) {
            // ensure we have an instance of es.Sort
            var sort = params;
            if (!(params instanceof es.Sort)) {
                sort = es.newSort(params);
            }
            this.removeSortBy(sort);
            this.sort.unshift(sort);
        };
        this.removeSortBy = function(params) {
            // ensure we have an instance of es.Sort
            var sort = params;
            if (!(params instanceof es.Sort)) {
                sort = es.newSort(params);
            }
            var removes = [];
            for (var i = 0; i < this.sort.length; i++) {
                var so = this.sort[i];
                if (so.field === sort.field) {
                    removes.push(i);
                }
            }
            removes = removes.sort().reverse();
            for (var i = 0; i < removes.length; i++) {
                this.sort.splice(removes[i], 1);
            }
        };
        this.getSortBy = function() {
            return this.sort;
        };

        this.setSourceFilters = function(params) {
            if (!this.source) {
                this.source = {include: [], exclude: []};
            }
            if (params.include) {
                this.source.include = params.include;
            }
            if (params.exclude) {
                this.source.exclude = params.exclude;
            }
        };

        this.addSourceFilters = function(params) {
            if (!this.source) {
                this.source = {include: [], exclude: []};
            }
            if (params.include) {
                if (this.source.include) {
                    Array.prototype.push.apply(this.source.include, params.include);
                } else {
                    this.source.include = params.include;
                }
            }
            if (params.exclude) {
                if (this.source.include) {
                    Array.prototype.push.apply(this.source.include, params.include);
                } else {
                    this.source.include = params.include;
                }
            }
        };

        this.getSourceIncludes = function() {
            if (!this.source) {
                return [];
            }
            return this.source.include;
        };

        this.getSourceExcludes = function() {
            if (!this.source) {
                return [];
            }
            return this.source.exclude;
        };

        this.addFacet = function() {};
        this.removeFacet = function() {};
        this.clearFacets = function() {};

        this.getAggregation = function(params) {
            var name = params.name;
            for (var i = 0; i < this.aggs.length; i++) {
                var a = this.aggs[i];
                if (a.name === name) {
                    return a;
                }
            }
        };
        this.addAggregation = function(agg, overwrite) {
            if (overwrite) {
                this.removeAggregation(agg.name);
            } else {
                for (var i = 0; i < this.aggs.length; i++) {
                    if (this.aggs[i].name === agg.name) {
                        return;
                    }
                }
            }
            this.aggs.push(agg);
        };
        this.removeAggregation = function(name) {
            var removes = [];
            for (var i = 0; i < this.aggs.length; i++) {
                if (this.aggs[i].name === name) {
                    removes.push(i);
                }
            }
            removes = removes.sort().reverse();
            for (var i = 0; i < removes.length; i++) {
                this.aggs.splice(removes[i], 1);
            }
        };
        this.clearAggregations = function() {
            this.aggs = [];
        };
        this.listAggregations = function() {
            return this.aggs;
        };

        this.addMust = function(filter) {
            var existing = this.listMust(filter);
            if (existing.length === 0) {
                this.must.push(filter);
            }
        };
        this.listMust = function(template) {
            return this.listFilters({boolType: "must", template: template});
        };
        this.removeMust = function(template) {
            var removes = [];
            for (var i = 0; i < this.must.length; i++) {
                var m = this.must[i];
                if (m.matches(template)) {
                    removes.push(i);
                }
            }
            removes = removes.sort().reverse();
            for (var i = 0; i < removes.length; i++) {
                this.must.splice(removes[i], 1);
            }
            // return the count of filters that were removed
            return removes.length;
        };
        this.clearMust = function() {
            this.must = [];
        };

        this.addMustNot = function(filter) {
            var existing = this.listMustNot(filter);
            if (existing.length === 0) {
                this.mustNot.push(filter);
            }
        };
        this.listMustNot = function(template) {
            return this.listFilters({boolType: "must_not", template: template});
        };
        this.removeMustNot = function(template) {
            var removes = [];
            for (var i = 0; i < this.mustNot.length; i++) {
                var m = this.mustNot[i];
                if (m.matches(template)) {
                    removes.push(i);
                }
            }
            removes = removes.sort().reverse();
            for (var i = 0; i < removes.length; i++) {
                this.mustNot.splice(removes[i], 1);
            }
            // return the count of filters that were removed
            return removes.length;
        };
        this.clearMustNot = function() {
            this.mustNot = [];
        };

        this.addShould = function() {};
        this.listShould = function() {};
        this.removeShould = function() {};
        this.clearShould = function() {};



        /////////////////////////////////////////////////
        // interrogative functions

        this.hasFilters = function() {
            return this.must.length > 0 || this.should.length > 0 || this.mustNot.length > 0
        };

        // in general better to use the listMust, listShould, listMustNot, directly.
        // those methods each use this method internally anyway
        this.listFilters = function(params) {
            var boolType = params.boolType || "must";
            var template = params.template || false;

            //var field = params.field || false;
            //var filterType = params.filterType || false;

            // first get the boolean filter field that we're going to look in
            var bool = [];
            if (boolType === "must") {
                bool = this.must;
            } else if (boolType === "should") {
                bool = this.should;
            } else if (boolType === "must_not") {
                bool = this.mustNot;
            }

            if (!template) {
                return bool;
            }
            var l = [];
            for (var i = 0; i < bool.length; i++) {
                var m = bool[i];
                if (m.matches(template)) {
                    l.push(m);
                }
            }
            return l;
        };

        ////////////////////////////////////////////////
        // create, parse, serialise functions

        this.merge = function(source) {
            // merge this query (in place) with the provided query, where the provided
            // query is dominant (i.e. any properties it has override this object)
            //
            // These are the merge rules:
            // this.filtered - take from source
            // this.size - take from source if set
            // this.from - take from source if set
            // this.fields - append any new ones from source
            // this.aggs - append any new ones from source, overwriting any with the same name
            // this.must - append any new ones from source
            // this.mustNot - append any new ones from source
            // this.queryString - take from source if set
            // this.sort - prepend any from source
            // this.source - append any new ones from source

            this.filtered = source.filtered;
            if (source.size) {
                this.size = source.size;
            }
            if (source.from) {
                this.from = source.from;
            }
            if (source.fields && source.fields.length > 0) {
                for (var i = 0; i < source.fields.length; i++) {
                    this.addField(source.fields[i]);
                }
            }
            var aggs = source.listAggregations();
            for (var i = 0; i < aggs.length; i++) {
                this.addAggregation(aggs[i], true);
            }
            var must = source.listMust();
            for (var i = 0; i < must.length; i++) {
                this.addMust(must[i]);
            }
            let mustNot = source.listMustNot();
            for (let i = 0; i < mustNot.length; i++) {
                this.addMustNot(mustNot[i]);
            }
            if (source.getQueryString()) {
                this.setQueryString(source.getQueryString())
            }
            var sorts = source.getSortBy();
            if (sorts && sorts.length > 0) {
                sorts.reverse();
                for (var i = 0; i < sorts.length; i++) {
                    this.prependSortBy(sorts[i])
                }
            }
            var includes = source.getSourceIncludes();
            var excludes = source.getSourceExcludes();
            this.addSourceFilters({include: includes, exclude: excludes});
        };

        this.objectify = function(params) {
            if (!params) {
                params = {};
            }
            // this allows you to specify which bits of the query get objectified
            var include_query_string = params.include_query_string === undefined ? true : params.include_query_string;
            var include_filters = params.include_filters === undefined ? true : params.include_filters;
            var include_paging = params.include_paging === undefined ? true : params.include_paging;
            var include_sort = params.include_sort === undefined ? true : params.include_sort;
            var include_fields = params.include_fields === undefined ? true : params.include_fields;
            var include_aggregations = params.include_aggregations === undefined ? true : params.include_aggregations;
            var include_source_filters = params.include_source_filters === undefined ? true : params.include_source_filters;

            // queries will be separated in queries and bool filters, which may then be
            // combined later
            var q = {};
            var query_part = {};
            var bool = {};

            // query string
            if (this.queryString && include_query_string) {
                $.extend(query_part, this.queryString.objectify());
            }

            if (include_filters) {
                // add any MUST filters
                if (this.must.length > 0) {
                    var musts = [];
                    for (var i = 0; i < this.must.length; i++) {
                        var m = this.must[i];
                        musts.push(m.objectify());
                    }
                    bool["must"] = musts;
                }
                // add any must_not filters
                if (this.mustNot.length > 0) {
                    let mustNots = [];
                    for (var i = 0; i < this.mustNot.length; i++) {
                        var m = this.mustNot[i];
                        mustNots.push(m.objectify());
                    }
                    bool["must_not"] = mustNots;
                }
            }

            var qpl = Object.keys(query_part).length;
            var bpl = Object.keys(bool).length;
            var query_portion = {};
            if (qpl === 0 && bpl === 0) {
                query_portion["match_all"] = {};
            } else if (qpl === 0 && bpl > 0) {
                query_portion["bool"] = bool;
            } else if (qpl > 0 && bpl === 0) {
                query_portion = query_part;
            } else if (qpl > 0 && bpl > 0) {
                query_portion["bool"] = bool;
                query_portion["bool"]["must"].push(query_part);
            }
            q["query"] = query_portion;

            if (include_paging) {
                // page size
                if (this.size !== undefined && this.size !== false) {
                    q["size"] = this.size;
                }

                // page number (from)
                if (this.from) {
                    q["from"] = this.from;
                }
            }

            // sort option
            if (this.sort.length > 0 && include_sort) {
                q["sort"] = [];
                for (var i = 0; i < this.sort.length; i++) {
                    q.sort.push(this.sort[i].objectify())
                }
            }

            // fields
            if (this.fields.length > 0 && include_fields) {
                q["stored_fields"] = this.fields;
            }

            // add any aggregations
            if (this.aggs.length > 0 && include_aggregations) {
                q["aggs"] = {};
                for (var i = 0; i < this.aggs.length; i++) {
                    var agg = this.aggs[i];
                    $.extend(q.aggs, agg.objectify())
                }
            }

            // add the source filters
            if (include_source_filters && this.source && (this.source.include || this.source.exclude)) {
                q["_source"] = {};
                if (this.source.include.length > 0) {
                    q["_source"]["include"] = this.source.include;
                }
                if (this.source.exclude.length > 0) {
                    q["_source"]["exclude"] = this.source.exclude;
                }
            }

            // set whether to track the total
            if (this.trackTotalHits) {
                q["track_total_hits"] = true;
            }

            return q;
        };

        // When a query is requested as a string, dump via JSON.
        es.Query.prototype.toString = function queryToString() {
            return JSON.stringify(this.objectify())
        };

        this.parse = function(obj) {

            function parseBool(bool, target) {
                if (bool.must) {
                    for (var i = 0; i < bool.must.length; i++) {
                        var type = Object.keys(bool.must[i])[0];
                        var fil = es.filterFactory(type, {raw: bool.must[i]});
                        if (fil && type !== "query_string") {
                            target.addMust(fil);
                        } else if (fil && type === "query_string") {
                            // FIXME: this will work fine as long as there are no nested bools
                            target.setQueryString(fil);
                        }
                    }
                }
                if (bool.must_not) {
                    for (var i = 0; i < bool.must_not.length; i++) {
                        var type = Object.keys(bool.must_not[i])[0];
                        var fil = es.filterFactory(type, {raw: bool.must_not[i]});
                        if (fil) {
                            target.addMustNot(fil);
                        }
                    }
                }
            }

            function parseQuery(q, target) {
                var keys = Object.keys(q);
                for (var i = 0; i < keys.length; i++) {
                    var type = keys[i];
                    if (type === "bool") {
                        parseBool(q.bool, target);
                        continue;
                    }
                    var impl = es.filterFactory(type, {raw: q[type]});
                    if (impl) {
                        if (type === "query_string") {
                            target.setQueryString(impl);
                        }
                        // FIXME: other non-filtered queries?
                    }
                }
            }

            // parse the query itself
            if (obj.query) {
                if (obj.query.filtered) {
                    this.filtered = true;
                    var bool = obj.query.filtered.filter.bool;
                    if (bool) {
                        parseBool(bool, this);
                    }
                    var q = obj.query.filtered.query;
                    parseQuery(q, this);
                } else {
                    var q = obj.query;
                    parseQuery(q, this);
                }
            }

            if (obj.size) {
                this.size = obj.size;
            }

            if (obj.from) {
                this.from = obj.from;
            }

            if (obj.stored_fields) {
                this.fields = obj.stored_fields;
            }

            if (obj.sort) {
                for (var i = 0; i < obj.sort.length; i++) {
                    var so = obj.sort[i];
                    this.addSortBy(es.newSort({raw: so}));
                }
            }

            if (obj.aggs || obj.aggregations) {
                var aggs = obj.aggs ? obj.aggs : obj.aggregations;
                var anames = Object.keys(aggs);
                for (var i = 0; i < anames.length; i++) {
                    var name = anames[i];
                    var agg = aggs[name];
                    var type = Object.keys(agg)[0];
                    var raw = {};
                    raw[name] = agg;
                    var oa = es.aggregationFactory(type, {raw: raw});
                    if (oa) {
                        this.addAggregation(oa);
                    }
                }
            }

            if (obj._source) {
                var source = obj._source;
                var include = [];
                var exclude = [];

                if (typeof source === "string") {
                    include.push(source);
                }
                else if (Array.isArray(source)) {
                    include = source;
                } else {
                    if (source.hasOwnProperty("include")) {
                        include = source.include;
                    }
                    if (source.hasOwnProperty("exclude")) {
                        exclude = source.exclude;
                    }
                }
                this.setSourceFilters({include: include, exclude: exclude});
            }
        };

        ///////////////////////////////////////////////////////////
        // final part of construction - set the dynamic properties
        // via their setters

        if (params.queryString) {
            this.setQueryString(params.queryString);
        }

        if (params.sort) {
            this.setSortBy(params.sort);
        }

        // finally, if we're given a raw query, parse it
        if (params.raw) {
            this.parse(params.raw)
        }
    },

    ///////////////////////////////////////////////
    // Query String

    newQueryString : function(params) {
        if (!params) { params = {} }
        return new es.QueryString(params);
    },
    QueryString : function(params) {
        this.queryString = params.queryString || false;
        this.defaultField = params.defaultField || false;
        this.defaultOperator = params.defaultOperator || "OR";

        this.fuzzify = params.fuzzify || false;     // * or ~
        this.escapeSet = params.escapeSet || es.specialCharsSubSet;
        this.pairs = params.pairs || es.characterPairs;
        this.unEscapeSet = params.unEscapeSet || es.specialChars;

        this.objectify = function() {
            var qs = this._escape(this._fuzzify(this.queryString));
            var obj = {query_string : {query : qs}};
            if (this.defaultOperator) {
                obj.query_string["default_operator"] = this.defaultOperator;
            }
            if (this.defaultField) {
                obj.query_string["default_field"] = this.defaultField;
            }
            return obj;
        };

        this.parse = function(obj) {
            if (obj.query_string) {
                obj = obj.query_string;
            }
            this.queryString = this._unescape(obj.query);
            if (obj.default_operator) {
                this.defaultOperator = obj.default_operator;
            }
            if (obj.default_field) {
                this.defaultField = obj.default_field;
            }
        };

        this._fuzzify = function(str) {
            if (!this.fuzzify || !(this.fuzzify === "*" || this.fuzzify === "~")) {
                return str;
            }

            if (!(str.indexOf('*') === -1 && str.indexOf('~') === -1 && str.indexOf(':') === -1)) {
                return str;
            }

            var pq = "";
            var optparts = str.split(' ');
            for (var i = 0; i < optparts.length; i++) {
                var oip = optparts[i];
                if (oip.length > 0) {
                    oip = oip + this.fuzzify;
                    this.fuzzify == "*" ? oip = "*" + oip : false;
                    pq += oip + " ";
                }
            }
            return pq;
        };

        this._escapeRegExp = function(string) {
            return string.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1");
        };

        this._replaceAll = function(string, find, replace) {
            return string.replace(new RegExp(this._escapeRegExp(find), 'g'), replace);
        };

        this._unReplaceAll = function(string, find) {
            return string.replace(new RegExp("\\\\(" + this._escapeRegExp(find) + ")", 'g'), "$1");
        };

        this._paired = function(string, pair) {
            var matches = (string.match(new RegExp(this._escapeRegExp(pair), "g"))) || [];
            return matches.length % 2 === 0;
        };

        this._escape = function(str) {
            // make a copy of the special characters (we may modify it in a moment)
            var scs = this.escapeSet.slice(0);

            // first check for pairs, and push any extra characters to be escaped
            for (var i = 0; i < this.pairs.length; i++) {
                var char = this.pairs[i];
                if (!this._paired(str, char)) {
                    scs.push(char);
                }
            }

            // now do the escape
            for (var i = 0; i < scs.length; i++) {
                var char = scs[i];
                str = this._replaceAll(str, char, "\\" + char);
            }

            return str;
        };

        this._unescape = function(str) {
            for (var i = 0; i < this.unEscapeSet.length; i++) {
                var char = this.unEscapeSet[i];
                str = this._unReplaceAll(str, char)
            }
            return str;
        };

        if (params.raw) {
            this.parse(params.raw);
        }
    },

    //////////////////////////////////////////////
    // Sort Option

    newSort : function(params) {
        if (!params) { params = {} }
        return new es.Sort(params);
    },
    Sort : function(params) {
        this.field = params.field || "_score";
        this.order = params.order || "desc";

        this.objectify = function() {
            var obj = {};
            obj[this.field] = {order: this.order};
            return obj;
        };

        this.parse = function(obj) {
            this.field = Object.keys(obj)[0];
            if (obj[this.field].order) {
                this.order = obj[this.field].order;
            }
        };

        if (params.raw) {
            this.parse(params.raw);
        }
    },

    //////////////////////////////////////////////
    // Root Aggregation and aggregation implementations

    newAggregation : function(params) {
        if (!params) { params = {} }
        return new es.Aggregation(params);
    },
    Aggregation : function(params) {
        this.name = params.name;
        this.aggs = params.aggs || [];

        this.addAggregation = function(agg) {
            for (var i = 0; i < this.aggs.length; i++) {
                if (this.aggs[i].name === agg.name) {
                    return;
                }
            }
            this.aggs.push(agg);
        };
        this.removeAggregation = function() {};
        this.clearAggregations = function() {};

        // for use by sub-classes, for their convenience in rendering
        // the overall structure of the aggregation to an object
        this._make_aggregation = function(type, body) {
            var obj = {};
            obj[this.name] = {};
            obj[this.name][type] = body;

            if (this.aggs.length > 0) {
                obj[this.name]["aggs"] = {};
                for (var i = 0; i < this.aggs.length; i++) {
                    $.extend(obj[this.name]["aggs"], this.aggs[i].objectify())
                }
            }

            return obj;
        };

        this._parse_wrapper = function(obj, type) {
            this.name = Object.keys(obj)[0];
            var body = obj[this.name][type];

            var aggs = obj[this.name].aggs ? obj[this.name].aggs : obj[this.name].aggregations;
            if (aggs) {
                var anames = Object.keys(aggs);
                for (var i = 0; i < anames.length; i++) {
                    var name = anames[i];
                    var agg = aggs[anames[i]];
                    var subtype = Object.keys(agg)[0];
                    var raw = {};
                    raw[name] = agg;
                    var oa = es.aggregationFactory(subtype, {raw: raw});
                    if (oa) {
                        this.addAggregation(oa);
                    }
                }
            }

            return body;
        }
    },

    newTermsAggregation : function(params) {
        if (!params) { params = {} }
        es.TermsAggregation.prototype = es.newAggregation(params);
        return new es.TermsAggregation(params);
    },
    TermsAggregation : function(params) {
        this.field = params.field || false;
        this.size = params.size || 10;

        // set the ordering for the first time
        this.orderBy = "_count";
        if (params.orderBy) {
            this.orderBy = params.orderBy;
            if (this.orderBy[0] !== "_") {
                this.orderBy = "_" + this.orderBy;
            }
        }
        this.orderDir = params.orderDir || "desc";

        // provide a method to set and normalise the ordering in future
        this.setOrdering = function(orderBy, orderDir) {
            this.orderBy = orderBy;
            if (this.orderBy[0] !== "_") {
                this.orderBy = "_" + this.orderBy;
            }
            this.orderDir = orderDir;
        };

        this.objectify = function() {
            var body = {field: this.field, size: this.size, order: {}};
            body.order[this.orderBy] = this.orderDir;
            return this._make_aggregation("terms", body);
        };

        this.parse = function(obj) {
            var body = this._parse_wrapper(obj, "terms");
            this.field = body.field;
            if (body.size) {
                this.size = body.size;
            }
            if (body.order) {
                this.orderBy = Object.keys(body.order)[0];
                this.orderDir = body.order[this.orderBy];
            }
        };

        if (params.raw) {
            this.parse(params.raw);
        }
    },

    newCardinalityAggregation : function(params) {
        if (!params) { params = {} }
        es.CardinalityAggregation.prototype = es.newAggregation(params);
        return new es.CardinalityAggregation(params);
    },
    CardinalityAggregation : function(params) {
        this.field = es.getParam(params.field, false);

        this.objectify = function() {
            var body = {field: this.field};
            return this._make_aggregation("cardinality", body);
        };

        this.parse = function(obj) {
            var body = this._parse_wrapper(obj, "cardinality");
            this.field = body.field;
        };

        if (params.raw) {
            this.parse(params.raw);
        }
    },

    newRangeAggregation : function(params) {
        if (!params) { params = {} }
        es.RangeAggregation.prototype = es.newAggregation(params);
        return new es.RangeAggregation(params);
    },
    RangeAggregation : function(params) {
        this.field = params.field || false;
        this.ranges = params.ranges || [];

        this.objectify = function() {
            var body = {field: this.field, ranges: this.ranges};
            return this._make_aggregation("range", body);
        };

        this.parse = function(obj) {
            var body = this._parse_wrapper(obj, "range");
            this.field = body.field;
            this.ranges = body.ranges;
        };

        if (params.raw) {
            this.parse(params.raw);
        }
    },

    newGeoDistanceAggregation : function(params) {
        if (!params) { params = {} }
        es.GeoDistanceAggregation.prototype = es.newAggregation(params);
        return new es.GeoDistanceAggregation(params);
    },
    GeoDistanceAggregation : function(params) {
        this.field = params.field || false;
        this.lat = params.lat || false;
        this.lon = params.lon || false;
        this.unit = params.unit || "m";
        this.distance_type = params.distance_type || "sloppy_arc";
        this.ranges = params.ranges || [];

        this.objectify = function() {
            var body = {
                field: this.field,
                origin: {lat : this.lat, lon: this.lon},
                unit : this.unit,
                distance_type : this.distance_type,
                ranges: this.ranges
            };
            return this._make_aggregation("geo_distance", body);
        };

        this.parse = function(obj) {
            var body = this._parse_wrapper(obj, "geo_distance");
            this.field = body.field;

            // FIXME: only handles the lat/lon object - but there are several forms
            // this origin could take
            var origin = body.origin;
            if (origin.lat) {
                this.lat = origin.lat;
            }
            if (origin.lon) {
                this.lon = origin.lon;
            }

            if (body.unit) {
                this.unit = body.unit;
            }

            if (body.distance_type) {
                this.distance_type = body.distance_type;
            }

            this.ranges = body.ranges;
        };

        if (params.raw) {
            this.parse(params.raw);
        }
    },

    newGeohashGridAggregation : function(params) {
        if (!params) { params = {} }
        es.GeohashGridAggregation.prototype = es.newAggregation(params);
        return new es.GeohashGridAggregation(params);
    },
    GeohashGridAggregation : function(params) {
        this.field = params.field || false;
        this.precision = params.precision || 3;

        this.objectify = function() {
            var body = {
                field: this.field,
                precision: this.precision
            };
            return this._make_aggregation("geohash_grid", body);
        };

        this.parse = function(obj) {
            var body = this._parse_wrapper(obj, "geohash_grid");
            this.field = body.field;
            this.precision = body.precision;
        };

        if (params.raw) {
            this.parse(params.raw);
        }
    },

    newStatsAggregation : function(params) {
        if (!params) { params = {} }
        es.StatsAggregation.prototype = es.newAggregation(params);
        return new es.StatsAggregation(params);
    },
    StatsAggregation : function(params) {
        this.field = params.field || false;

        this.objectify = function() {
            var body = {field: this.field};
            return this._make_aggregation("stats", body);
        };

        this.parse = function(obj) {

        };

        if (params.raw) {
            this.parse(params.raw);
        }
    },

    newSumAggregation : function(params) {
        if (!params) { params = {} }
        es.SumAggregation.prototype = es.newAggregation(params);
        return new es.SumAggregation(params);
    },
    SumAggregation : function(params) {
        this.field = params.field || false;

        this.objectify = function() {
            var body = {field: this.field};
            return this._make_aggregation("sum", body);
        };

        this.parse = function(obj) {

        };

        if (params.raw) {
            this.parse(params.raw);
        }
    },

    newDateHistogramAggregation : function(params) {
        if (!params) { params = {} }
        es.DateHistogramAggregation.prototype = es.newAggregation(params);
        return new es.DateHistogramAggregation(params);
    },
    DateHistogramAggregation : function(params) {
        this.field = params.field || false;
        this.interval = params.interval || "month";
        this.format = params.format || false;

        this.objectify = function() {
            var body = {field: this.field, interval: this.interval};
            if (this.format) {
                body["format"] = this.format;
            }
            return this._make_aggregation("date_histogram", body);
        };

        this.parse = function(obj) {
            var body = this._parse_wrapper(obj, "date_histogram");
            this.field = body.field;
            if (body.interval) {
                this.interval = body.interval;
            }
            if (body.format) {
                this.format = body.format;
            }
        };

        if (params.raw) {
            this.parse(params.raw);
        }
    },

    newFiltersAggregation : function(params) {
        if (!params) { params = {} }
        es.FiltersAggregation.prototype = es.newAggregation(params);
        return new es.FiltersAggregation(params);
    },
    FiltersAggregation : function(params) {
        this.filters = params.filters || {};

        this.objectify = function() {
            var body = {filters: this.filters};
            return this._make_aggregation("filters", body);
        };

        this.parse = function(obj) {
            var body = this._parse_wrapper(obj, "filters");
            this.filters = body.filters;
        };

        if (params.raw) {
            this.parse(params.raw);
        }
    },

    ///////////////////////////////////////////////////
    // Filters

    newFilter : function(params) {
        if (!params) { params = {} }
        return new es.Filter(params);
    },
    Filter : function(params) {
        this.field = params.field;
        this.type_name = params.type_name;
        this.matches = function(other) {
            // type must match
            if (other.type_name !== this.type_name) {
                return false;
            }
            // field (if set) must match
            if (other.field && other.field !== this.field) {
                return false;
            }
            // otherwise this matches
            return true;
        };
        this.objectify = function() {};
        this.parse = function() {};
    },

    newTermFilter : function(params) {
        if (!params) { params = {} }
        params.type_name = "term";
        es.TermFilter.prototype = es.newFilter(params);
        return new es.TermFilter(params);
    },
    TermFilter : function(params) {
        // this.filter handled by superclass
        this.value = params.value || false;

        this.matches = function(other) {
            // ask the parent object first
            // var pm = this.__proto__.matches.call(this, other);
            var pm = Object.getPrototypeOf(this).matches.call(this, other);
            if (!pm) {
                return false;
            }
            // value (if set) must match
            if (other.value && other.value !== this.value) {
                return false;
            }

            return true;
        };

        this.objectify = function() {
            var obj = {term : {}};
            obj.term[this.field] = this.value;
            return obj;
        };

        this.parse = function(obj) {
            if (obj.term) {
                obj = obj.term;
            }
            this.field = Object.keys(obj)[0];
            this.value = obj[this.field];
        };

        if (params.raw) {
            this.parse(params.raw);
        }
    },

    newExistsFilter : function(params) {
        if (!params) { params = {} }
        params.type_name = "term";
        es.ExistsFilter.prototype = es.newFilter(params);
        return new es.ExistsFilter(params);
    },
    ExistsFilter : function(params) {
        this.objectify = function() {
            return {exists : {field: this.field}};
        };

        this.parse = function(obj) {
            if (obj.exists) {
                obj = obj.exists;
            }
            this.field = obj.field;
        };

        if (params.raw) {
            this.parse(params.raw);
        }
    },

    newTermsFilter : function(params) {
        if (!params) { params = {} }
        params.type_name = "terms";
        es.TermsFilter.prototype = es.newFilter(params);
        return new es.TermsFilter(params);
    },
    TermsFilter : function(params) {
        // this.field handled by superclass
        this.values = params.values || false;
        this.execution = params.execution || false;

        this.matches = function(other) {
            // ask the parent object first
            // var pm = this.__proto__.matches.call(this, other);
            var pm = Object.getPrototypeOf(this).matches.call(this, other);
            if (!pm) {
                return false;
            }

            // values (if set) must be the same list
            if (other.values) {
                if (other.values.length !== this.values.length) {
                    return false;
                }
                for (var i = 0; i < other.values.length; i++) {
                    if ($.inArray(other.values[i], this.values) === -1) {
                        return false;
                    }
                }
            }

            return true;
        };

        this.objectify = function() {
            var val = this.values || [];
            var obj = {terms : {}};
            obj.terms[this.field] = val;
            if (this.execution) {
                obj.terms["execution"] = this.execution;
            }
            return obj;
        };

        this.parse = function(obj) {
            if (obj.terms) {
                obj = obj.terms;
            }
            this.field = Object.keys(obj)[0];
            this.values = obj[this.field];
            if (obj.execution) {
                this.execution = obj.execution;
            }
        };

        this.add_term = function(term) {
            if (!this.values) {
                this.values = [];
            }
            if ($.inArray(term, this.values) === -1) {
                this.values.push(term);
            }
        };

        this.has_term = function(term) {
            if (!this.values) {
                return false;
            }
            return $.inArray(term, this.values) >= 0;
        };

        this.remove_term = function(term) {
            if (!this.values) {
                return;
            }
            var idx = $.inArray(term, this.values);
            if (idx >= 0) {
                this.values.splice(idx, 1);
            }
        };

        this.has_terms = function() {
            return (this.values !== false && this.values.length > 0)
        };

        this.term_count = function() {
            return this.values === false ? 0 : this.values.length;
        };

        this.clear_terms = function() {
            this.values = false;
        };

        if (params.raw) {
            this.parse(params.raw);
        }
    },

    newRangeFilter : function(params) {
        if (!params) { params = {} }
        params.type_name = "range";
        es.RangeFilter.prototype = es.newFilter(params);
        return new es.RangeFilter(params);
    },
    RangeFilter : function(params) {
        // this.field handled by superclass
        this.lt = es.getParam(params.lt, false);
        this.lte = es.getParam(params.lte, false);
        this.gte = es.getParam(params.gte, false);
        this.format = es.getParam(params.format, false);

        // normalise the values to strings
        if (this.lt) { this.lt = this.lt.toString() }
        if (this.lte) { this.lte = this.lte.toString() }
        if (this.gte) { this.gte = this.gte.toString() }

        this.matches = function(other) {
            // ask the parent object first
            // var pm = this.__proto__.matches.call(this, other);
            var pm = Object.getPrototypeOf(this).matches.call(this, other);
            if (!pm) {
                return false;
            }

            // ranges (if set) must match
            if (other.lt) {
                if (other.lt !== this.lt) {
                    return false;
                }
            }
            if (other.lte) {
                if (other.lte !== this.lte) {
                    return false;
                }
            }
            if (other.gte) {
                if (other.gte !== this.gte) {
                    return false;
                }
            }

            if (other.format) {
                if (other.format !== this.format) {
                    return false;
                }
            }

            return true;
        };

        this.objectify = function() {
            var obj = {range: {}};
            obj.range[this.field] = {};
            if (this.lte !== false) {
                obj.range[this.field]["lte"] = this.lte;
            }
            if (this.lt !== false && this.lte === false) {
                obj.range[this.field]["lt"] = this.lt;
            }
            if (this.gte !== false) {
                obj.range[this.field]["gte"] = this.gte;
            }
            if (this.format !== false) {
                obj.range[this.field]["format"] = this.format;
            }
            return obj;
        };

        this.parse = function(obj) {
            if (obj.range) {
                obj = obj.range;
            }
            this.field = Object.keys(obj)[0];
            if (obj[this.field].lte !== undefined && obj[this.field].lte !== false) {
                this.lte = obj[this.field].lte;
            }
            if (obj[this.field].lt !== undefined && obj[this.field].lt !== false) {
                this.lt = obj[this.field].lt;
            }
            if (obj[this.field].gte !== undefined && obj[this.field].gte !== false) {
                this.gte = obj[this.field].gte;
            }
            if (obj[this.field].format !== undefined && obj[this.field].format !== false) {
                this.format = obj[this.field].format;
            }
        };

        if (params.raw) {
            this.parse(params.raw);
        }
    },

    newGeoDistanceRangeFilter : function(params) {
        if (!params) { params = {} }
        params.type_name = "geo_distance_range";
        es.GeoDistanceRangeFilter.prototype = es.newFilter(params);
        return new es.GeoDistanceRangeFilter(params);
    },
    GeoDistanceRangeFilter : function(params) {
        // this.field is handled by superclass
        this.lt = params.lt || false;
        this.gte = params.gte || false;
        this.lat = params.lat || false;
        this.lon = params.lon || false;
        this.unit = params.unit || "m";

        this.objectify = function() {
            var obj = {geo_distance_range: {}};
            obj.geo_distance_range[this.field] = {lat: this.lat, lon: this.lon};
            if (this.lt) {
                obj.geo_distance_range["lt"] = this.lt + this.unit;
            }
            if (this.gte) {
                obj.geo_distance_range["gte"] = this.gte + this.unit;
            }
            return obj;
        };

        this.parse = function(obj) {
            function endsWith(str, suffix) {
                return str.indexOf(suffix, str.length - suffix.length) !== -1;
            }

            function splitUnits(str) {
                var unit = false;
                for (var i = 0; i < es.distanceUnits.length; i++) {
                    var cu = es.distanceUnits[i];
                    if (endsWith(str, cu)) {
                        str = str.substring(0, str.length - cu.length);
                        unit = str.substring(str.length - cu.length);
                    }
                }

                return [str, unit];
            }

            if (obj.geo_distance_range) {
                obj = obj.geo_distance_range;
            }
            this.field = Object.keys(obj)[0];
            this.lat = obj[this.field].lat;
            this.lon = obj[this.field].lon;

            var lt = obj[this.field].lt;
            var gte = obj[this.field].gte;

            if (lt) {
                lt = lt.trim();
                var parts = splitUnits(lt);
                this.lt = parts[0];
                this.unit = parts[1];
            }

            if (gte) {
                gte = gte.trim();
                var parts = splitUnits(gte);
                this.gte = parts[0];
                this.unit = parts[1];
            }
        };

        if (params.raw) {
            this.parse(params.raw);
        }
    },

    newGeoBoundingBoxFilter : function(params) {
        if (!params) { params = {} }
        params.type_name = "geo_bounding_box";
        return edges.instantiate(es.GeoBoundingBoxFilter, params, es.newFilter);
    },
    GeoBoundingBoxFilter : function(params) {
        this.top_left = params.top_left || false;
        this.bottom_right = params.bottom_right || false;

        this.matches = function(other) {
            // ask the parent object first
            var pm = Object.getPrototypeOf(this).matches.call(this, other);
            if (!pm) {
                return false;
            }
            if (other.top_left && other.top_left !== this.top_left) {
                return false;
            }
            if (other.bottom_right && other.bottom_right !== this.bottom_right) {
                return false;
            }
            return true;
        };

        this.objectify = function() {
            var obj = {geo_bounding_box : {}};
            obj.geo_bounding_box[this.field] = {
                top_left: this.top_left,
                bottom_right: this.bottom_right
            };
            return obj;
        };

        this.parse = function(obj) {
            if (obj.geo_bounding_box) {
                obj = obj.geo_bounding_box;
            }
            this.field = Object.keys(obj)[0];
            this.top_left = obj[this.field].top_left;
            this.bottom_right = obj[this.field].bottom_right;
        };

        if (params.raw) {
            this.parse(params.raw);
        }
    },

    ////////////////////////////////////////////////////
    // The result object

    newResult : function(params) {
        if (!params) { params = {} }
        return new es.Result(params);
    },
    Result : function(params) {
        this.data = params.raw;

        this.buckets = function(agg_name) {
            return this.data.aggregations[agg_name].buckets;
        };

        this.aggregation = function(agg_name) {
            return this.data.aggregations[agg_name];
        };

        this.results = function() {
            var res = [];
            if (this.data.hits && this.data.hits.hits) {
                for (var i = 0; i < this.data.hits.hits.length; i++) {
                    var source = this.data.hits.hits[i];
                    if ("_source" in source) {
                        res.push(source._source);
                    } else if ("_fields" in source) {
                        res.push(source._fields);
                    } else {
                        res.push(source);
                    }
                }
            }
            return res;
        };

        this.total = function() {
            if (this.data.hits && this.data.hits.total && this.data.hits.total.value) {
                return parseInt(this.data.hits.total.value);
            }
            return false;
        }
    },


    ////////////////////////////////////////////////////
    // Primary functions for interacting with elasticsearch

    doQuery : function(params) {
        // extract the parameters of the request
        var success = params.success;
        var error = params.error;
        var complete = params.complete;
        var search_url = params.search_url;
        var queryobj = params.queryobj;
        var datatype = params.datatype;

        // serialise the query
        var querystring = JSON.stringify(queryobj);

        // prep the callbacks (they are connected)
        var error_callback = es.queryError(error);
        var success_callback = es.querySuccess(success, error_callback);

        // make the call to the elasticsearch web service
        if (es.requestMethod === "get") {
            $.ajax({
                type: "get",
                url: search_url,
                data: {source: querystring},
                dataType: datatype,
                success: success_callback,
                error: error_callback,
                complete: complete
            });
        } else if (es.requestMethod === "post") {
            $.ajax({
                type: "post",
                url: search_url,
                data: querystring,
                contentType: "application/json",
                dataType: datatype,
                success: success_callback,
                error: error_callback,
                complete: complete
            });
        } else {
            throw "es.requestMethod must be either 'get' or 'post";
        }
    },

    querySuccess : function(callback, error_callback) {
        return function(data) {
            if (data.hasOwnProperty("error")) {
                error_callback(data);
                return;
            }

            var result = es.newResult({raw: data});
            callback(result);
        }
    },

    queryError : function(callback) {
        return function(data) {
            if (callback) {
                callback(data);
            } else {
                throw new Error(data);
            }
        }
    },

    /////////////////////////////////////////////////////

    getParam : function(value, def) {
        return value !== undefined ? value : def;
    }
};