// License: LGPL-3.0-or-later
// based upon https://github.com/davidkalosi/js-money
import isFunction from 'lodash/isFunction'

const assertSameCurrency = function (left: any, right: any) {
  if (left.currency !== right.currency)
    throw new Error('Different currencies');
};

const assertType = function (other: any) {
  if (!(other instanceof Money))
    throw new TypeError('Instance of Money required');
};

const assertOperand = function (operand: any) {
  if (isNaN(parseFloat(operand)) && !isFinite(operand))
    throw new TypeError('Operand must be a number');
};

/**
 * Represents a monetary amount. For safety, all Money objects are immutable. All of the functions in this class create a new Money object.
 * 
 * To create a new Money object is to use the `fromCents` function.
 * @export
 * @class Money
 */
export class Money {

  readonly currency:string

  protected constructor(readonly amountInCents: number, currency: string) {
    this.currency = currency.toLowerCase()
    const methodsToBind = [this.equals, this.add, this.subtract, this.multiply, this.divide, this.allocate,
      this.compare, this.greaterThan, this.greaterThanOrEqual, this.lessThan,
      this.lessThanOrEqual, this.isZero, this.isPositive, this.isNegative,
      this.toJSON]
      methodsToBind.forEach((func:Function) => Object.bind(func))
    
    Object.freeze(this);
  }

  /**
   * Create a `Money` object with the given number of cents and the ISO currency unit
   * @static
   * @param  {number} amountInCents 
   * @param  {string} currency 
   * @return Money 
   * @memberof Money
   */
  static fromCents(amountInCents: number, currency: string): Money {
    return new Money(amountInCents, currency)
  }

  /**
   * Create a `Money` object with the given number if smallest monetary units and the ISO currency. Another name for the `fromCents` function.
   * @static
   * @memberof Money
   */
  static fromSMU=Money.fromCents

  /**
  * Returns true if the two instances of Money are equal, false otherwise.
  *
  * @param {Money} other
  * @returns {Boolean}
  */
  equals(other: Money): boolean {
    var self = this;
    assertType(other);

    return self.amountInCents === other.amountInCents &&
      self.currency === other.currency;
  };

  /**
   * Adds the two objects together creating a new Money instance that holds the result of the operation.
   *
   * @param {Money} other
   * @returns {Money}
   */
  add(other: Money): Money {
    var self = this;
    assertType(other);
    assertSameCurrency(self, other);

    return new Money(self.amountInCents + other.amountInCents, self.currency);
  };

  /**
   * Subtracts the two objects creating a new Money instance that holds the result of the operation.
   *
   * @param {Money} other
   * @returns {Money}
   */
  subtract(other: Money): Money {
    var self = this;
    assertType(other);
    assertSameCurrency(self, other);

    return new Money(self.amountInCents - other.amountInCents, self.currency);
  };

  /**
   * Multiplies the object by the multiplier returning a new Money instance that holds the result of the operation.
   *
   * @param {Number} multiplier
   * @param {(x:number) => number} [fn=Math.round]
   * @returns {Money}
   */
  multiply(multiplier: number, fn?: Function): Money {
    if (!isFunction(fn))
      fn = Math.round;

    assertOperand(multiplier);
    var amount = fn(this.amountInCents * multiplier);

    return new Money(amount, this.currency);
  };

  /**
   * Divides the object by the multiplier returning a new Money instance that holds the result of the operation.
   *
   * @param {Number} divisor
   * @param {(x:number) => number} [fn=Math.round]
   * @returns {Money}
   */
  divide(divisor: number, fn?: (x: number) => number): Money {
    if (!isFunction(fn))
      fn = Math.round;

    assertOperand(divisor);
    var amount = fn(this.amountInCents / divisor);

    return new Money(amount, this.currency);
  };

  /**
   * Allocates fund bases on the ratios provided returing an array of objects as a product of the allocation.
   *
   * @param {Array} other
   * @param {Money[]}
   */
  allocate(ratios: number[]): Money[] {
    var self = this;
    var remainder = self.amountInCents;
    var results: Money[] = [];
    var total = 0;

    ratios.forEach(function (ratio) {
      total += ratio;
    });

    ratios.forEach(function (ratio) {
      var share = Math.floor(self.amountInCents * ratio / total)
      results.push(new Money(share, self.currency));
      remainder -= share;
    });

    for (var i = 0; remainder > 0; i++) {
      results[i] = new Money(results[i].amountInCents + 1, results[i].currency);
      remainder--;
    }

    return results;
  };

  /**
   * Compares two instances of Money.
   *
   * @param {Money} other
   * @returns {Number}
   */
  compare(other: Money): number {
    var self = this;

    assertType(other);
    assertSameCurrency(self, other);

    if (self.amountInCents === other.amountInCents)
      return 0;

    return self.amountInCents > other.amountInCents ? 1 : -1;
  };

  /**
   * Checks whether the value represented by this object is greater than the other.
   *
   * @param {Money} other
   * @returns {boolean}
   */
  greaterThan(other: Money): boolean {
    return 1 === this.compare(other);
  };

  /**
   * Checks whether the value represented by this object is greater or equal to the other.
   *
   * @param {Money} other
   * @returns {boolean}
   */
  greaterThanOrEqual(other: Money): boolean {
    return 0 <= this.compare(other);
  };

  /**
   * Checks whether the value represented by this object is less than the other.
   *
   * @param {Money} other
   * @returns {boolean}
   */
  lessThan(other: Money): boolean {
    return -1 === this.compare(other);
  };

  /**
   * Checks whether the value represented by this object is less than or equal to the other.
   *
   * @param {Money} other
   * @returns {boolean}
   */
  lessThanOrEqual(other: Money): boolean {
    return 0 >= this.compare(other);
  };

  /**
   * Returns true if the amount is zero.
   *
   * @returns {boolean}
   */
  isZero(): boolean {
    return this.amountInCents === 0;
  };

  /**
   * Returns true if the amount is positive.
   *
   * @returns {boolean}
   */
  isPositive(): boolean {
    return this.amountInCents > 0;
  };

  /**
   * Returns true if the amount is negative.
   *
   * @returns {boolean}
   */
  isNegative(): boolean {
    return this.amountInCents < 0;
  };

  /**
   * Returns a serialised version of the instance.
   *
   * @returns {{amount: number, currency: string}}
   */
  toJSON(): { amountInCents: number; currency: string; } {
    return {
      amountInCents: this.amountInCents,
      currency: this.currency
    };
  };


}