/*
 * Copyright (c) 2012 - 2014 Ngewi Fet <ngewif@gmail.com>
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.gnucash.android.model;


import android.support.annotation.NonNull;
import android.util.Log;

import com.crashlytics.android.Crashlytics;

import java.math.BigDecimal;
import java.math.BigInteger;
import java.math.RoundingMode;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.text.NumberFormat;
import java.util.Locale;

/**
 * Money represents a money amount and a corresponding currency.
 * Money internally uses {@link BigDecimal} to represent the amounts, which enables it 
 * to maintain high precision afforded by BigDecimal. Money objects are immutable and
 * most operations return new Money objects.
 * Money String constructors should not be passed any locale-formatted numbers. Only
 * {@link Locale#US} is supported e.g. "2.45" will be parsed as 2.45 meanwhile 
 * "2,45" will be parsed to 245 although that could be a decimal in {@link Locale#GERMAN}
 * 
 * @author Ngewi Fet<ngewif@gmail.com>
 *
 */
public final class Money implements Comparable<Money>{

	/**
	 * Currency of the account
	 */
	private Commodity mCommodity;

	/**
	 * Amount value held by this object
	 */
	private BigDecimal mAmount;

	/**
	 * Rounding mode to be applied when performing operations
	 * Defaults to {@link RoundingMode#HALF_EVEN}
	 */
	protected RoundingMode ROUNDING_MODE = RoundingMode.HALF_EVEN;

	/**
	 * Default currency code (according ISO 4217) 
	 * This is typically initialized to the currency of the device default locale,
	 * otherwise US dollars are used
	 */
	public static String DEFAULT_CURRENCY_CODE 	= "USD";

    /**
     * A zero instance with the currency of the default locale.
     * This can be used anywhere where a starting amount is required without having to create a new object
     */
    private static Money sDefaultZero;

    /**
     * Returns a Money instance initialized to the local currency and value 0
     * @return Money instance of value 0 in locale currency
     */
    public static Money getZeroInstance(){
		if (sDefaultZero == null) {
			sDefaultZero = new Money(BigDecimal.ZERO, Commodity.DEFAULT_COMMODITY);
		}
		return sDefaultZero;
    }

	/**
	 * Returns the {@link BigDecimal} from the {@code numerator} and {@code denominator}
	 * @param numerator Number of the fraction
	 * @param denominator Denominator of the fraction
	 * @return BigDecimal representation of the number
	 */
	public static BigDecimal getBigDecimal(long numerator, long denominator) {
		int scale;
		if (numerator == 0 && denominator == 0) {
			denominator = 1;
		}

		scale = Integer.numberOfTrailingZeros((int)denominator);
		return new BigDecimal(BigInteger.valueOf(numerator), scale);
	}

	/**
	 * Creates a new money amount
	 * @param amount Value of the amount
	 * @param commodity Commodity of the money
	 */
	public Money(BigDecimal amount, Commodity commodity){
		this.mCommodity = commodity;
		setAmount(amount); //commodity has to be set first. Because we use it's scale
	}

	/**
	 * Overloaded constructor.
	 * Accepts strings as arguments and parses them to create the Money object
	 * @param amount Numerical value of the Money
	 * @param currencyCode Currency code as specified by ISO 4217
	 */
	public Money(String amount, String currencyCode){
		//commodity has to be set first
		mCommodity = Commodity.getInstance(currencyCode);
		setAmount(new BigDecimal(amount));
	}

	/**
	 * Constructs a new money amount given the numerator and denominator of the amount.
	 * The rounding mode used for the division is {@link BigDecimal#ROUND_HALF_EVEN}
	 * @param numerator Numerator as integer
	 * @param denominator Denominator as integer
	 * @param currencyCode 3-character currency code string
	 */
	public Money(long numerator, long denominator, String currencyCode){
		mAmount = getBigDecimal(numerator, denominator);
		setCommodity(currencyCode);
	}

    /**
     * Copy constructor.
     * Creates a new Money object which is a clone of <code>money</code>
     * @param money Money instance to be cloned
     */
    public Money(Money money){
		setCommodity(money.getCommodity());
		setAmount(money.asBigDecimal());
    }

    /**
     * Creates a new Money instance with 0 amount and the <code>currencyCode</code>
     * @param currencyCode Currency to use for this money instance
     * @return Money object with value 0 and currency <code>currencyCode</code>
     */
    public static Money createZeroInstance(@NonNull String currencyCode){
		Commodity commodity = Commodity.getInstance(currencyCode);
        return new Money(BigDecimal.ZERO, commodity);
    }

	/**
	 * Returns the commodity used by the Money
	 * @return Instance of commodity
	 */
	public Commodity getCommodity(){
		return mCommodity;
	}

	/**
	 * Returns a new <code>Money</code> object the currency specified by <code>currency</code> 
	 * and the same value as this one. No value exchange between the currencies is performed.
	 * @param commodity {@link Commodity} to assign to new <code>Money</code> object
	 * @return {@link Money} object with same value as current object, but with new <code>currency</code>
	 */
    public Money withCurrency(@NonNull Commodity commodity){
		return new Money(mAmount, commodity);
	}

	/**
	 * Sets the commodity for the Money
	 * <p>No currency conversion is performed</p>
	 * @param commodity Commodity instance
	 */
	private void setCommodity(@NonNull Commodity commodity){
		this.mCommodity = commodity;
	}

	/**
	 * Sets the commodity for the Money
	 * @param currencyCode ISO 4217 currency code
	 */
	private void setCommodity(@NonNull String currencyCode){
		mCommodity = Commodity.getInstance(currencyCode);
	}

	/**
	 * Returns the GnuCash format numerator for this amount.
	 * <p>Example: Given an amount 32.50$, the numerator will be 3250</p>
	 * @return GnuCash numerator for this amount
	 */
	public long getNumerator() {
		try {
			return mAmount.scaleByPowerOfTen(getScale()).longValueExact();
		} catch (ArithmeticException e) {
			String msg = "Currency " + mCommodity.getCurrencyCode() +
					" with scale " + getScale() +
					" has amount " + mAmount.toString();
			Crashlytics.log(msg);
			Log.e(getClass().getName(), msg);
			throw e;
		}
	}

	/**
	 * Returns the GnuCash amount format denominator for this amount
	 * <p>The denominator is 10 raised to the power of number of fractional digits in the currency</p>
	 * @return GnuCash format denominator
	 */
	public long getDenominator() {
		int scale = getScale();
		return BigDecimal.ONE.scaleByPowerOfTen(scale).longValueExact();
	}

	/**
	 * Returns the scale (precision) used for the decimal places of this amount.
	 * <p>The scale used depends on the commodity</p>
	 * @return Scale of amount as integer
	 */
	private int getScale() {
		int scale = mCommodity.getSmallestFractionDigits();
		if (scale < 0) {
			scale = mAmount.scale();
		}
		if (scale < 0) {
			scale = 0;
		}
		return scale;
	}

	/**
	 * Returns the amount represented by this Money object
	 * <p>The scale and rounding mode of the returned value are set to that of this Money object</p>
	 * @return {@link BigDecimal} valure of amount in object
	 */
	public BigDecimal asBigDecimal() {
		return mAmount.setScale(mCommodity.getSmallestFractionDigits(), RoundingMode.HALF_EVEN);
	}
	
	/**
	 * Returns the amount this object
	 * @return Double value of the amount in the object
	 */
	public double asDouble(){
		return mAmount.doubleValue();
	}

	/**
	 * An alias for {@link #toPlainString()}
	 * @return Money formatted as a string (excludes the currency)
	 */
	public String asString(){
		return toPlainString();
	}
	
	/**
	 * Returns a string representation of the Money object formatted according to 
	 * the <code>locale</code> and includes the currency symbol. 
	 * The output precision is limited to the number of fractional digits supported by the currency
	 * @param locale Locale to use when formatting the object
	 * @return String containing formatted Money representation
	 */
    public String formattedString(Locale locale){

		NumberFormat currencyFormat = NumberFormat.getCurrencyInstance(locale);

		String symbol;
		//if we want to show US Dollars for locales which also use Dollars, for example, Canada
		if (mCommodity.equals(Commodity.USD) && !locale.equals(Locale.US)) {
			symbol = "US$";
		} else {
			symbol = mCommodity.getSymbol();
		}
		DecimalFormatSymbols decimalFormatSymbols = ((DecimalFormat)currencyFormat).getDecimalFormatSymbols();
		decimalFormatSymbols.setCurrencySymbol(symbol);
		((DecimalFormat)currencyFormat).setDecimalFormatSymbols(decimalFormatSymbols);
		currencyFormat.setMinimumFractionDigits(mCommodity.getSmallestFractionDigits());
		currencyFormat.setMaximumFractionDigits(mCommodity.getSmallestFractionDigits());

		return currencyFormat.format(asDouble());
/*
// 	old currency formatting code
		NumberFormat formatter = NumberFormat.getInstance(locale);
		formatter.setMinimumFractionDigits(mCommodity.getSmallestFractionDigits());
		formatter.setMaximumFractionDigits(mCommodity.getSmallestFractionDigits());
		Currency currency = Currency.getInstance(mCommodity.getCurrencyCode());
		return formatter.format(asDouble()) + " " + currency.getSymbol(locale);
*/
	}

    /**
     * Equivalent to calling formattedString(Locale.getDefault())
     * @return String formatted Money representation in default locale
     */
    public String formattedString(){
        return formattedString(Locale.getDefault());
    }

	/**
	 * Returns a new Money object whose amount is the negated value of this object amount.
	 * The original <code>Money</code> object remains unchanged.
	 * @return Negated <code>Money</code> object
	 */
    public Money negate(){
		return new Money(mAmount.negate(), mCommodity);
	}
	
	/**
	 * Sets the amount value of this <code>Money</code> object
	 * @param amount {@link BigDecimal} amount to be set
	 */
	private void setAmount(@NonNull BigDecimal amount) {
		mAmount = amount.setScale(mCommodity.getSmallestFractionDigits(), ROUNDING_MODE);
	}

	/**
	 * Returns a new <code>Money</code> object whose value is the sum of the values of 
	 * this object and <code>addend</code>.
	 * 
	 * @param addend Second operand in the addition.
	 * @return Money object whose value is the sum of this object and <code>money</code>
	 * @throws CurrencyMismatchException if the <code>Money</code> objects to be added have different Currencies
	 */
    public Money add(Money addend){
		if (!mCommodity.equals(addend.mCommodity))
			throw new CurrencyMismatchException();
		
		BigDecimal bigD = mAmount.add(addend.mAmount);
		return new Money(bigD, mCommodity);
	}

	/**
	 * Returns a new <code>Money</code> object whose value is the difference of the values of 
	 * this object and <code>subtrahend</code>.
	 * This object is the minuend and the parameter is the subtrahend
	 * @param subtrahend Second operand in the subtraction.
	 * @return Money object whose value is the difference of this object and <code>subtrahend</code>
	 * @throws CurrencyMismatchException if the <code>Money</code> objects to be added have different Currencies
	 */
    public Money subtract(Money subtrahend){
		if (!mCommodity.equals(subtrahend.mCommodity))
			throw new CurrencyMismatchException();
		
		BigDecimal bigD = mAmount.subtract(subtrahend.mAmount);		
		return new Money(bigD, mCommodity);
	}
	
	/**
	 * Returns a new <code>Money</code> object whose value is the quotient of the values of 
	 * this object and <code>divisor</code>.
	 * This object is the dividend and <code>divisor</code> is the divisor
	 * <p>This method uses the rounding mode {@link BigDecimal#ROUND_HALF_EVEN}</p>
	 * @param divisor Second operand in the division.
	 * @return Money object whose value is the quotient of this object and <code>divisor</code>
	 * @throws CurrencyMismatchException if the <code>Money</code> objects to be added have different Currencies
	 */
    public Money divide(Money divisor){
		if (!mCommodity.equals(divisor.mCommodity))
			throw new CurrencyMismatchException();
		
		BigDecimal bigD = mAmount.divide(divisor.mAmount, mCommodity.getSmallestFractionDigits(), ROUNDING_MODE);
		return new Money(bigD, mCommodity);
	}
	
	/**
	 * Returns a new <code>Money</code> object whose value is the quotient of the division of this objects 
	 * value by the factor <code>divisor</code>
	 * @param divisor Second operand in the addition.
	 * @return Money object whose value is the quotient of this object and <code>divisor</code>
	 */
    public Money divide(int divisor){
		Money moneyDiv = new Money(new BigDecimal(divisor), mCommodity);
		return divide(moneyDiv);
	}
	
	/**
	 * Returns a new <code>Money</code> object whose value is the product of the values of 
	 * this object and <code>money</code>.
	 * 
	 * @param money Second operand in the multiplication.
	 * @return Money object whose value is the product of this object and <code>money</code>
	 * @throws CurrencyMismatchException if the <code>Money</code> objects to be added have different Currencies
	 */
    public Money multiply(Money money){
		if (!mCommodity.equals(money.mCommodity))
			throw new CurrencyMismatchException();
		
		BigDecimal bigD = mAmount.multiply(money.mAmount);		
		return new Money(bigD, mCommodity);
	}
	
	/**
	 * Returns a new <code>Money</code> object whose value is the product of this object
	 * and the factor <code>multiplier</code>
	 * <p>The currency of the returned object is the same as the current object</p>
	 * @param multiplier Factor to multiply the amount by.
	 * @return Money object whose value is the product of this objects values and <code>multiplier</code>
	 */
    public Money multiply(int multiplier){
		Money moneyFactor = new Money(new BigDecimal(multiplier), mCommodity);
		return multiply(moneyFactor);
	}

	/**
	 * Returns a new <code>Money</code> object whose value is the product of this object
	 * and the factor <code>multiplier</code>
	 * @param multiplier Factor to multiply the amount by.
	 * @return Money object whose value is the product of this objects values and <code>multiplier</code>
	 */
	public Money multiply(@NonNull BigDecimal multiplier){
		return new Money(mAmount.multiply(multiplier), mCommodity);
	}

	/**
	 * Returns true if the amount held by this Money object is negative
	 * @return <code>true</code> if the amount is negative, <code>false</code> otherwise.
	 */
	public boolean isNegative(){
		return mAmount.compareTo(BigDecimal.ZERO) == -1;
	}
	
	/**
	 * Returns the string representation of the amount (without currency) of the Money object.
	 *
	 * <p>This string is not locale-formatted. The decimal operator is a period (.)
	 * For a locale-formatted version, see the method overload {@link #toLocaleString(Locale)}</p>
	 * @return String representation of the amount (without currency) of the Money object
	 */
	public String toPlainString(){
		return mAmount.setScale(mCommodity.getSmallestFractionDigits(), ROUNDING_MODE).toPlainString();
	}

	/**
	 * Returns a locale-specific representation of the amount of the Money object (excluding the currency)
	 *
	 * @return String representation of the amount (without currency) of the Money object
	 */
	public String toLocaleString(){
		return String.format(Locale.getDefault(), "%.2f", asDouble());
	}

	/**
	 * Returns the string representation of the Money object (value + currency) formatted according
	 * to the default locale
	 * @return String representation of the amount formatted with default locale
	 */
    @Override
	public String toString() {
		return formattedString(Locale.getDefault());
	}
		
	@Override
	public int hashCode() {
		final int prime = 31;
		int result = 1;
		result = prime * result + (mAmount.hashCode());
		result = prime * result + (mCommodity.hashCode());
		return result;
	}

	/** //FIXME: equality failing for money objects
	 * Two Money objects are only equal if their amount (value) and currencies are equal
	 * @param obj Object to compare with
	 * @return <code>true</code> if the objects are equal, <code>false</code> otherwise
	 */
	@Override
	public boolean equals(Object obj) {
		if (this == obj)
			return true;
		if (obj == null)
			return false;
		if (getClass() != obj.getClass())
			return false;
		Money other = (Money) obj;
		if (!mAmount.equals(other.mAmount))
			return false;
		if (!mCommodity.equals(other.mCommodity))
			return false;
		return true;
	}

	@Override
	public int compareTo(@NonNull Money another) {
		if (!mCommodity.equals(another.mCommodity))
			throw new CurrencyMismatchException();
		return mAmount.compareTo(another.mAmount);
	}

    /**
     * Returns a new instance of {@link Money} object with the absolute value of the current object
     * @return Money object with absolute value of this instance
     */
    public Money abs() {
        return new Money(mAmount.abs(), mCommodity);
    }

	/**
	 * Checks if the value of this amount is exactly equal to zero.
	 * @return {@code true} if this money amount is zero, {@code false} otherwise
	 */
    public boolean isAmountZero() {
		return mAmount.compareTo(BigDecimal.ZERO) == 0;
	}

	public class CurrencyMismatchException extends IllegalArgumentException{
		@Override
		public String getMessage() {
			return "Cannot perform operation on Money instances with different currencies";
		}
	}
}
