/************************************************************************
 * This file is part of EspoCRM.
 *
 * EspoCRM – Open Source CRM application.
 * Copyright (C) 2014-2025 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
 * Website: https://www.espocrm.com
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
 *
 * The interactive user interfaces in modified source and object code versions
 * of this program must display Appropriate Legal Notices, as required under
 * Section 5 of the GNU Affero General Public License version 3.
 *
 * In accordance with Section 7(b) of the GNU Affero General Public License version 3,
 * these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
 ************************************************************************/

/** @module collection */

import Model from 'model';
import {Events, View as BullView} from 'bullbone';
import _ from 'underscore';

/**
 * On sync with backend.
 *
 * @event Collection#sync
 * @param {Collection} collection A collection.
 * @param {Object} response Response from backend.
 * @param {Object} o Options.
 */

/**
 * Any number of models have been added, removed or changed.
 *
 * @event Collection#update
 * @param {Collection} collection A collection.
 * @param {Object} o Options.
 */

/**
 * On reset.
 *
 * @event Collection#reset
 * @param {Collection} collection A collection.
 * @param {Object} o Options.
 */

/**
 * A collection.
 *
 * @mixes Bull.Events
 * @copyright Credits to Backbone.js.
 */
class Collection {

    /**
     * An entity type.
     *
     * @type {string|null}
     */
    entityType = null

    /**
     * A total number of records.
     *
     * @type {number}
     */
    total = 0

    /**
     * A current offset (for pagination).
     *
     * @type {number}
     */
    offset = 0

    /**
     * A max size (for pagination).
     *
     * @type {number}
     */
    maxSize = 20

    /**
     * An order.
     *
     * @type {boolean|'asc'|'desc'|null}
     */
    order = null

    /**
     * An order-by field.
     *
     * @type {string|null}
     */
    orderBy = null

    /**
     * A where clause.
     *
     * @type {module:search-manager~whereItem[]|null}
     */
    where = null

    /**
     * @deprecated
     */
    whereAdditional = null

    /**
     * A length correction.
     *
     * @type {number}
     */
    lengthCorrection = 0

    /**
     * A max max-size.
     *
     * @type {number}
     */
    maxMaxSize = 0

    /**
     * A where function.
     *
     * @type {function(): Object[]}
     */
    whereFunction

    /**
     * A last sync request promise.
     *
     * @type {module:ajax.AjaxPromise|null}
     */
    lastSyncPromise = null

    /**
     * A parent model. To be used for own purposes. E.g. to have access to a parent from related models.
     *
     * @type {import('model').default}
     */
    parentModel

    /**
     * @param {Model[]|null} [models] Models.
     * @param {{
     *     entityType?: string,
     *     model?: Model.prototype,
     *     defs?: module:model~defs,
     *     order?: 'asc'|'desc'|boolean|null,
     *     orderBy?: string|null,
     *     urlRoot?: string,
     *     url?: string,
     * }} [options] Options.
     */
    constructor(models, options) {
        options = {...options};

        if (options.model) {
            this.model = options.model;
        }

        this._reset();

        if (options.entityType) {
            this.entityType = options.entityType;
            /** @deprecated */
            this.name = this.entityType;
        }

        /**
         * A root URL.
         *
         * @public
         * @type {string|null}
         */
        this.urlRoot = options.urlRoot || this.urlRoot || this.entityType;

        /**
         * An URL.
         *
         * @type {string|null}
         */
        this.url = options.url || this.url || this.urlRoot;

        this.orderBy = options.orderBy || this.orderBy;
        this.order = options.order || this.order;

        this.defaultOrder = this.order;
        this.defaultOrderBy = this.orderBy;

        /** @type {module:model~defs} */
        this.defs = options.defs || {};

        /**
         * @type {{
         *     primaryFilter?: string,
         *     boolFilterList?: string[],
         *     textFilter?: string,
         *     select?: string,
         *     q?: string,
         * } | Record<string, *>}
         */
        this.data = {};

        /**
         * @private
         * @type {Model#}
         */
        this.model = options.model || Model;

        if (models) {
            this.reset(models, {silent: true, ...options});
        }
    }

    /**
     * Add models or a model.
     *
     * @param {Model[]|Model|Record[]|Record} models Models ar a model.
     * @param {{
     *     merge?: boolean,
     *     at?: number,
     *     silent?: boolean,
     * }} [options] Options. `at` – position; `merge` – merge existing models, otherwise, they are ignored.
     * @return {this}
     * @fires Collection#update
     */
    add(models, options) {
        this.set(models, {merge: false, ...options, ...addOptions});

        return this;
    }

    /**
     * Remove models or a model.
     *
     * @param {Model[]|Model|string} models Models, a model or a model ID.
     * @param {{
     *     silent?: boolean,
     * } & Object.<string, *>} [options] Options.
     * @return {this}
     * @fires Collection#update
     */
    remove(models, options) {
        options = {...options};

        const singular = !_.isArray(models);

        models = singular ? [models] : models.slice();

        const removed = this._removeModels(models, options);

        if (!options.silent && removed.length) {
            options.changes = {
                added: [],
                merged: [],
                removed: removed,
            };

            this.trigger('update', this, options);
        }

        return this;
    }

    /**
     * @protected
     * @param {Model[]|Model|Record[]} models Models ar a model.
     * @param {{
     *     silent?: boolean,
     *     at?: number,
     *     prepare?: boolean,
     *     add?: boolean,
     *     merge?: boolean,
     *     remove?: boolean,
     *     index?: number,
     * } & Object.<string, *>} [options]
     * @return {Model[]}
     */
    set(models, options) {
        if (models == null) {
            return [];
        }

        options = {...setOptions, ...options};

        if (options.prepare && !this._isModel(models)) {
            models = this.prepareAttributes(models, options) || [];
        }

        const singular = !_.isArray(models);
        models = singular ? [models] : models.slice();

        let at = options.at;

        if (at != null) {
            at = +at;
        }

        if (at > this.length) {
            at = this.length;
        }

        if (at < 0) {
            at += this.length + 1;
        }

        const set = [];
        const toAdd = [];
        const toMerge = [];
        const toRemove = [];
        const modelMap = {};

        const add = options.add;
        const merge = options.merge;
        const remove = options.remove;

        let model, i;

        for (i = 0; i < models.length; i++) {
            model = models[i];

            const existing = this._get(model);

            if (existing) {
                if (merge && model !== existing) {
                    let attributes = this._isModel(model) ?
                        model.attributes :
                        model;

                    if (options.prepare) {
                        attributes = existing.prepareAttributes(attributes, options);
                    }

                    existing.set(attributes, options);
                    toMerge.push(existing);
                }

                if (!modelMap[existing.cid]) {
                    modelMap[existing.cid] = true;
                    set.push(existing);
                }

                models[i] = existing;
            }
            else if (add) {
                model = models[i] = this._prepareModel(model);

                if (model) {
                    toAdd.push(model);

                    this._addReference(model, options);

                    modelMap[model.cid] = true;
                    set.push(model);
                }
            }
        }

        // Remove stale models.
        if (remove) {
            for (i = 0; i < this.length; i++) {
                model = this.models[i];

                if (!modelMap[model.cid]) {
                    toRemove.push(model);
                }
            }

            if (toRemove.length) {
                this._removeModels(toRemove, options);
            }
        }

        let orderChanged = false;
        const replace = add && remove;

        if (set.length && replace) {
            orderChanged =
                this.length !== set.length ||
                _.some(this.models, (m, index) => {
                    return m !== set[index];
                });

            this.models.length = 0;
            splice(this.models, set, 0);

            this.length = this.models.length;
        }
        else if (toAdd.length) {
            splice(this.models, toAdd, at == null ? this.length : at);

            this.length = this.models.length;
        }

        if (!options.silent) {
            for (i = 0; i < toAdd.length; i++) {
                if (at != null) {
                    options.index = at + i;
                }

                model = toAdd[i];

                model.trigger('add', model, this, options);
            }

            if (orderChanged) {
                this.trigger('sort', this, options);
            }

            if (toAdd.length || toRemove.length || toMerge.length) {
                options.changes = {
                    added: toAdd,
                    removed: toRemove,
                    merged: toMerge
                };

                this.trigger('update', this, options);
            }
        }

        return models;
    }

    /**
     * Reset.
     *
     * @param {Model[]|null} [models] Models to replace the collection with.
     * @param {{
     *     silent?: boolean,
     * } & Object.<string, *>} [options]
     * @return {this}
     * @fires Collection#reset
     */
    reset(models, options) {
        this.lengthCorrection = 0;

        options = options ? _.clone(options) : {};

        for (let i = 0; i < this.models.length; i++) {
            this._removeReference(this.models[i], options);
        }

        options.previousModels = this.models;

        this._reset();

        if (models) {
            this.add(models, {silent: true, ...options});
        }

        if (!options.silent) {
            this.trigger('reset', this, options);
        }

        return this;
    }

    /**
     * Add a model at the end.
     *
     * @param {Model} model A model.
     * @param {{
     *     silent?: boolean,
     * }} [options] Options
     * @return {this}
     */
    push(model, options) {
        this.add(model, {at: this.length, ...options});

        return this;
    }

    /**
     * Remove and return the last model.
     *
     * @param {{
     *     silent?: boolean,
     * }} [options] Options
     * @return {Model|null}
     */
    pop(options) {
        const model = this.at(this.length - 1);

        if (!model) {
            return null;
        }

        this.remove(model, options);

        return model;
    }

    /**
     * Add a model to the beginning.
     *
     * @param {Model} model A model.
     * @param {{
     *     silent?: boolean,
     * }} [options] Options
     * @return {this}
     */
    unshift(model, options) {
        this.add(model, {at: 0, ...options});

        return this;
    }

    /**
     * Remove and return the first model.
     *
     * @param {{
     *     silent?: boolean,
     * }} [options] Options
     * @return {Model|null}
     */
    shift(options) {
        const model = this.at(0);

        if (!model) {
            return null;
        }

        this.remove(model, options);

        return model;
    }

    /**
     * Get a model by an ID.
     *
     * @todo Usage to _get.
     * @param {string} id An ID.
     * @return {Model|undefined}
     */
    get(id) {
        return this._get(id);
    }

    /**
     * Whether a model in the collection.
     *
     * @todo Usage to _has.
     * @param {string} id An ID.
     * @return {boolean}
     */
    has(id) {
        return this._has(id);
    }

    /**
     * Get a model by index.
     *
     * @param {number} index An index. Can be negative, then counted from the end.
     * @return {Model|undefined}
     */
    at(index) {
        if (index < 0) {
            index += this.length;
        }

        return this.models[index];
    }

    /**
     * Iterates through a collection.
     *
     * @param {function(Model)} callback A function.
     * @param {Object} [context] A context.
     */
    forEach(callback, context) {
        return this.models.forEach(callback, context);
    }

    /**
     * Get an index of a model. Returns -1 if not found.
     *
     * @param {Model} model A model
     * @return {number}
     */
    indexOf(model) {
        return this.models.indexOf(model);
    }

    /**
     * @private
     * @param {string|Object.<string, *>|Model} obj
     * @return {boolean}
     */
    _has(obj) {
        return !!this._get(obj)
    }

    /**
     * @private
     * @param {string|Object.<string, *>|Model} obj
     * @return {Model|undefined}
     */
    _get(obj) {
        if (obj == null) {
            return void 0;
        }

        return this._byId[obj] ||
            this._byId[this.modelId(obj.attributes || obj)] ||
            obj.cid && this._byId[obj.cid];
    }

    /**
     * @protected
     * @param {Object.<string, *>} attributes
     * @return {*}
     */
    modelId(attributes) {
        return attributes['id'];
    }

    /** @private */
    _reset() {
        /**
         * A number of records.
         */
        this.length = 0;

        /**
         * Models.
         *
         * @type {Model[]}
         */
        this.models = [];

        /** @private */
        this._byId  = {};
    }

    /**
     * @param {string} orderBy An order field.
     * @param {bool|null|'desc'|'asc'} [order] True for desc.
     * @returns {Promise}
     */
    sort(orderBy, order) {
        this.orderBy = orderBy;

        if (order === true) {
            order = 'desc';
        }
        else if (order === false) {
            order = 'asc';
        }

        this.order = order || 'asc';

        return this.fetch();
    }

    /**
     * Has previous page.
     *
     * @return {boolean}
     */
    hasPreviousPage() {
        return this.offset > 0;
    }

    /**
     * Has next page.
     *
     * @return {boolean}
     */
    hasNextPage() {
        return this.total - this.offset > this.length || this.total === -1;
    }

    /**
     * Next page.
     *
     * @returns {Promise}
     */
    nextPage() {
        return this.setOffset(this.offset + this.length);
    }

    /**
     * Previous page.
     *
     * @returns {Promise}
     */
    previousPage() {
        return this.setOffset(Math.max(0, this.offset - this.maxSize));
    }

    /**
     * First page.
     *
     * @returns {Promise}
     */
    firstPage() {
        return this.setOffset(0);
    }

    /**
     * Last page.
     *
     * @returns {Promise}
     */
    lastPage() {
        let offset = this.total - this.total % this.maxSize;

        if (offset === this.total) {
            offset = this.total - this.maxSize;
        }

        return this.setOffset(offset);
    }

    /**
     * Set an offset.
     *
     * @param {number} offset Offset.
     * @returns {Promise}
     */
    setOffset(offset) {
        if (offset < 0) {
            throw new RangeError('offset can not be less than 0');
        }

        if (
            offset > this.total &&
            this.total !== -1 &&
            this.total !== -2 &&
            offset > 0
        ) {
            throw new RangeError('offset can not be larger than total count');
        }

        this.offset = offset;

        return this.fetch({maxSize: this.maxSize});
    }

    /**
     * Has more.
     *
     * @return {boolean}
     */
    hasMore() {
        return this.total > (this.length + this.offset + this.lengthCorrection) || this.total === -1;
    }

    /**
     * Prepare attributes.
     *
     * @protected
     * @param {Object.<string, *>|Record[]} response A response from the backend.
     * @param {Object.<string, *>} options Options.
     * @returns {Object.<string, *>[]}
     */
    prepareAttributes(response, options) {
        this.total = response.total;

        // noinspection JSUnusedGlobalSymbols
        /**
         * @deprecated As of v8.4. Use 'sync' event to obtain any additional data from a response.
         */
        this.dataAdditional = response.additionalData || null;

        return response.list;
    }

    /**
     * Fetch from the backend.
     *
     * @param {{
     *     remove?: boolean,
     *     more?: boolean,
     *     offset?: number,
     *     maxSize?: number,
     *     orderBy?: string,
     *     order?: 'asc'|'desc',
     * } & Object.<string, *>} [options] Options.
     * @returns {Promise}
     * @fires Collection#sync Unless `{silent: true}`.
     */
    fetch(options) {
        options = {...options};

        options.data = {...options.data, ...this.data};

        this.offset = options.offset || this.offset;
        this.orderBy = options.orderBy || this.orderBy;
        this.order = options.order || this.order;
        this.where = options.where || this.where;

        const length = this.length + this.lengthCorrection;

        if ('maxSize' in options) {
            options.data.maxSize = options.maxSize;
        } else {
            options.data.maxSize = options.more ? this.maxSize : Math.max(length, this.maxSize);

            if (this.maxMaxSize && options.data.maxSize > this.maxMaxSize) {
                options.data.maxSize = this.maxMaxSize;
            }
        }

        options.data.offset = options.more ? (this.offset + length) : this.offset;
        options.data.orderBy = this.orderBy;
        options.data.order = this.order;
        options.data.whereGroup = this.getWhere();

        if (options.data.select) {
            options.data.attributeSelect = options.data.select;

            delete options.data.select;
        }

        options = {prepare: true, ...options};

        const success = options.success;

        options.success = response => {
            options.reset ?
                this.reset(response, options) :
                this.set(response, options);

            if (success) {
                success.call(options.context, this, response, options);
            }

            this.trigger('sync', this, response, options);
        };

        const error = options.error;

        options.error = response => {
            if (error) {
                error.call(options.context, this, response, options);
            }

            this.trigger('error', this, response, options);
        };

        this.lastSyncPromise = Model.prototype.sync.call(this, 'read', this, options);

        return this.lastSyncPromise;
    }

    /**
     * Is being fetched.
     *
     * @return {boolean}
     */
    isBeingFetched() {
        return this.lastSyncPromise && this.lastSyncPromise.getReadyState() < 4;
    }

    /**
     * Abort the last fetch.
     */
    abortLastFetch() {
        if (this.isBeingFetched()) {
            this.lastSyncPromise.abort();
        }
    }

    /**
     * Get a where clause.
     *
     * @returns {Object[]}
     */
    getWhere() {
        let where = (this.where || []).concat(this.whereAdditional || []);

        if (this.whereFunction) {
            where = where.concat(this.whereFunction() || []);
        }

        return where;
    }

    /**
     * Get an entity type.
     *
     * @returns {string}
     */
    getEntityType() {
        return this.entityType || this.name;
    }

    /**
     * Reset the order to default.
     */
    resetOrderToDefault() {
        this.orderBy = this.defaultOrderBy;
        this.order = this.defaultOrder;
    }

    /**
     * Set an order.
     *
     * @param {string|null} orderBy
     * @param {boolean|'asc'|'desc'|null} [order]
     * @param {boolean} [setDefault]
     */
    setOrder(orderBy, order, setDefault) {
        this.orderBy = orderBy;
        this.order = order;

        if (setDefault) {
            this.defaultOrderBy = orderBy;
            this.defaultOrder = order;
        }
    }

    /**
     * Clone.
     *
     * @return {import('collection').default}
     */
    clone() {
        const collection = new this.constructor(this.models, {
            model: this.model,
            entityType: this.entityType,
            defs: this.defs,
            orderBy: this.orderBy,
            order: this.order,
        });

        collection.name = this.name;
        collection.urlRoot = this.urlRoot;
        collection.url = this.url;
        collection.defaultOrder = this.defaultOrder;
        collection.defaultOrderBy = this.defaultOrderBy;
        collection.data = Espo.Utils.cloneDeep(this.data);
        collection.where = Espo.Utils.cloneDeep(this.where);
        collection.whereAdditional = Espo.Utils.cloneDeep(this.whereAdditional);
        collection.total = this.total;
        collection.offset = this.offset;
        collection.maxSize = this.maxSize;
        collection.maxMaxSize = this.maxMaxSize;
        collection.whereFunction = this.whereFunction;
        collection.parentModel = this.parentModel;

        return collection;
    }

    /**
     * Prepare an empty model instance.
     *
     * @return {Model}
     */
    prepareModel() {
        return this._prepareModel({});
    }

    // noinspection JSUnusedGlobalSymbols
    /**
     * Compose a URL for syncing. Called from Model.sync.
     *
     * @protected
     * @return {string}
     */
    composeSyncUrl() {
        return this.url;
    }

    /** @private */
    _isModel(object) {
        return object instanceof Model;
    }

    /** @private */
    _removeModels(models, options) {
        const removed = [];

        for (let i = 0; i < models.length; i++) {
            const model = this.get(models[i]);

            if (!model) {
                continue;
            }

            const index = this.models.indexOf(model);

            this.models.splice(index, 1);
            this.length--;

            delete this._byId[model.cid];
            const id = this.modelId(model.attributes);

            if (id != null) {
                delete this._byId[id];
            }

            if (!options.silent) {
                options.index = index;

                model.trigger('remove', model, this, options);
            }

            removed.push(model);

            this._removeReference(model, options);
        }

        return removed;
    }

    /** @private */
    _addReference(model) {
        this._byId[model.cid] = model;

        const id = this.modelId(model.attributes);

        if (id != null) {
            this._byId[id] = model;
        }

        model.on('all', this._onModelEvent, this);
    }

    /** @private */
    _removeReference(model) {
        delete this._byId[model.cid];

        const id = this.modelId(model.attributes);

        if (id != null) {
            delete this._byId[id];
        }

        if (this === model.collection) {
            delete model.collection;
        }

        model.off('all', this._onModelEvent, this);
    }

    /** @private */
    _onModelEvent(event, model, collection, options) {
        if (event === 'sync' && collection !== this) {
            return;
        }

        if (!model) {
            this.trigger.apply(this, arguments);

            return;
        }

        if ((event === 'add' || event === 'remove') && collection !== this) {
            return;
        }

        if (event === 'destroy') {
            this.remove(model, options);
        }

        if (event === 'change') {
            const prevId = this.modelId(model.previousAttributes());
            const id = this.modelId(model.attributes);

            if (prevId !== id) {
                if (prevId != null) {
                    delete this._byId[prevId];
                }

                if (id != null) {
                    this._byId[id] = model;
                }
            }
        }

        this.trigger.apply(this, arguments);
    }

    // noinspection JSDeprecatedSymbols
    /** @private*/
    _prepareModel(attributes) {
        if (this._isModel(attributes)) {
            if (!attributes.collection) {
                attributes.collection = this;
            }

            return attributes;
        }

        const ModelClass = this.model;

        // noinspection JSValidateTypes
        return new ModelClass(attributes, {
            collection: this,
            entityType: this.entityType || this.name,
            defs: this.defs,
        });
    }
}

Object.assign(Collection.prototype, Events);

Collection.extend = BullView.extend;

const setOptions = {
    add: true,
    remove: true,
    merge: true,
};

const addOptions = {
    add: true,
    remove: false,
};

const splice = (array, insert, at) => {
    at = Math.min(Math.max(at, 0), array.length);

    const tail = Array(array.length - at);
    const length = insert.length;
    let i;

    for (i = 0; i < tail.length; i++) {
        tail[i] = array[i + at];
    }

    for (i = 0; i < length; i++) {
        array[i + at] = insert[i];
    }

    for (i = 0; i < tail.length; i++) {
        array[i + length + at] = tail[i];
    }
};

export default Collection;
