/**
 * Binds the decorated function to the object instance.
 * Case 1: The target is an ES6 class (has a constructor function)
 *   it stores the function reference (in the private _toBind map) to bind it later in the constructor.
 *   In this case the @bindInConstructor decorator should be used on the target class or object.
 * Case 2: The target is a simple object
 *   it overrides the property descriptor, and binds the function on the first get (when it is used first)
 *   This case does not support inheritance.
 *
 * @param {object} target Class or object
 * @param {string} name Name of the function
 * @param {object} descriptor The descriptor of the property
 * @returns {object} The modified descriptor object
 */
export const bind = (target, name, descriptor) => {
    if (!target || !name || !descriptor || !(descriptor.value instanceof Function)) {
        throw new Error('The @bind decorator should be applied only on class or object functions!');
    }

    // if it has a constructor, collect the function names to bind
    if (target.constructor) {
        const key = _getKeyForObject(target.constructor);
        if (!_toBind[key]) {
            _toBind[key] = [];
        }
        _toBind[key].push(name);
        return descriptor;
    }

    // standard binding for non Component classes
    const { enumerable, configurable, writable, value } = descriptor;
    return {
        enumerable,
        configurable,
        get: function() {
            const bound = value.bind(this);
            Object.defineProperty(this, name, {
                enumerable,
                configurable,
                writable: writable !== false,
                value: bound,
            });
            return bound;
        },
        set: function(value) {
            if (descriptor.writable === false) {
                return;
            }
            Object.defineProperty(this, name, {
                enumerable,
                configurable,
                writable: true,
                value,
            });
        },
    };
};

/**
 * Object array for generating object keys
 * @type {Array}
 * @private
 */
const _objects = [];

/**
 * Creates a key for the input object
 * It simply returns the index of the object in the _objs array.
 * @param obj
 * @returns {number}
 * @private
 */
const _getKeyForObject = obj => {
    let idx = _objects.indexOf(obj);
    if (idx === -1) {
        _objects.push(obj);
        idx = _objects.indexOf(obj);
    }
    return idx;
};

/**
 * Map of class names and bound functions.
 * @type {{}}
 * @private
 */
const _toBind = {};

/**
 * Binds the collected methods to the instance in construction time. This allows to bind the overridden methods instead
 * of the parent method (which destroys the ability to call the super method in the child class).
 */
export const bindInConstructor = target => {
    if (!target.constructor) {
        return target;
    }

    return class extends target {
        constructor(...args) {
            super(...args);
            const key = _getKeyForObject(target);
            if (_toBind[key]) {
                for (let i in _toBind[key]) {
                    const fn = _toBind[key][i];
                    if (!this[fn]) {
                        throw new Error(`This instance has no function to bind: ${fn}`);
                    }
                    this[fn] = this[fn].bind(this);
                }
            }
        }
    };
};
