/*
 * Copyright (c) 2007 Thomas Knierim
 * http://www.thomasknierim.com
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU 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 General Public License for more details.
 *
 *  You should have received a copy of the GNU General Public License
 *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
package etudes;

import java.io.File;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.URL;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.TimeZone;

import org.apache.commons.lang.ArrayUtils;
import org.apache.commons.lang.time.DateUtils;
import org.xml.sax.Attributes;
import org.xml.sax.InputSource;
import org.xml.sax.XMLReader;
import org.xml.sax.helpers.DefaultHandler;
import org.xml.sax.helpers.XMLReaderFactory;

/**
 * CurrencyConverter provides an API for accessing the European Central Bank's
 * (ECB) foreign exchange rates. The published ECB rates contain exchange rates
 * for approx. 35 of the world's major currencies. They are updated daily at
 * 14:15 CET. These rates use EUR as reference currency and are specified with a
 * precision of 1/10000 of the currency unit (one hundredth cent). See:
 *
 * http://www.ecb.int/stats/exchange/eurofxref/html/index.en.html
 *
 * The convert() method performs currency conversions using either double values
 * or 64-bit long integer values. Long values are preferred in order to avoid
 * problems associated with floating point arithmetics. A local cache file is
 * used for storing exchange rates to reduce network latency. The cache file is
 * updated automatically when new exchange rates become available. It is
 * created/updated the first time a call to convert() is made.
 *
 * @version 1.0 2008-16-02
 * @author Thomas Knierim
 *
 */

/**
 * Downloaded from:
 *
 * http://www.thomasknierim.com/60/java/java-currency-conversion-class/
 *
 * NOTE: This class is not thread safe
 *
 * Updated to support conversion based on rates for the day conversion is requested for. To do this:
 * 1. Created a new inner class CurrencyConversionRate, which keeps track of exchange rates for a given day. Moved relevant helpers into it.
 * 2. Updated parse() to load data into appropriate conversion rate bucket.
 * 3. Converter keeps an map of rates for different dates, to answer conversion questions.
 * 4. Download 90 day file from ECB, instead of daily rate file.
 *
 * @author mdubey
 *
 */
public final class CurrencyConverter {

    private static CurrencyConverter instance = null;

    //private final String ecbRatesURL = "http://www.ecb.int/stats/eurofxref/eurofxref-daily.xml";
    private final String ecbRatesURL = "http://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist-90d.xml";
    transient private File cacheFile = null;
    private String cacheFileName = null;

    Map<Date, CurrencyConversionRate> conversionRates = new HashMap<Date, CurrencyConversionRate>(90);
    private Date startDate;
    private Date endDate;
    private Date refreshDate = null;
    private String lastError = null;

    private static class CurrencyConversionRate {
        private HashMap<String, Long> fxRates = new HashMap<String, Long>(40);
        private Date referenceDate = null;

        public HashMap<String, Long> getFxRates()
        {
            return fxRates;
        }
        public Date getReferenceDate()
        {
            return referenceDate;
        }
        public void setReferenceDate(Date referenceDate)
        {
            this.referenceDate = referenceDate;
        }

        /**
         * Converts a double precision floating point value from one currency to
         * another. Example: convert(29.95, "USD", "EUR") - converts $29.95 US Dollars
         * to Euro.
         *
         * @param amount
         *                Amount of money (in source currency) to be converted.
         * @param fromCurrency
         *                Three letter ISO 4217 currency code of source currency.
         * @param toCurrency
         *                Three letter ISO 4217 currency code of target currency.
         * @return Amount in target currency
         * @throws IOException
         *                 If cache file cannot be read/written or if URL cannot be
         *                 opened.
         * @throws ParseException
         *                 If an error occurs while parsing the XML cache file.
         * @throws IllegalArgumentException
         *                 If a wrong (non-existing) currency argument was supplied.
         */
        public double convert(double amount, String fromCurrency, String toCurrency)
                throws IOException, ParseException, IllegalArgumentException {
            if (checkCurrencyArgs(fromCurrency, toCurrency)) {
                amount *= fxRates.get(toCurrency);
                amount /= fxRates.get(fromCurrency);
            }
            return amount;
        }

        /**
         * Converts a long value from one currency to another. Internally long
         * values represent monetary amounts in 1/10000 of the currency unit, e.g.
         * the long value 975573l represents 97.5573 (precision = four digits after
         * comma). Using long values instead of floating point numbers prevents
         * imprecision / calculation errors resulting from floating point
         * arithmetics.
         *
         * @param amount
         *                Amount of money (in source currency) to be converted.
         * @param fromCurrency
         *                Three letter ISO 4217 currency code of source currency.
         * @param toCurrency
         *                Three letter ISO 4217 currency code of target currency.
         * @return Amount in target currency
         * @throws IOException
         *                 If cache file cannot be read/written or if URL cannot be
         *                 opened.
         * @throws ParseException
         *                 If an error occurs while parsing the XML cache file.
         * @throws IllegalArgumentException
         *                 If a wrong (non-existing) currency argument was supplied.
         */
        public long convert(long amount, String fromCurrency, String toCurrency)
                throws IOException, ParseException, IllegalArgumentException {
            if (checkCurrencyArgs(fromCurrency, toCurrency)) {
                amount *= fxRates.get(toCurrency);
                amount /= fxRates.get(fromCurrency);
            }
            return amount;
        }

        /**
         * Check whether currency arguments are valid and not equal.
         *
         * @param fromCurrency
         *                ISO 4217 source currency code.
         * @param toCurrency
         *                ISO 4217 target currency code.
         * @return true if both currency arguments are not equal.
         * @throws IOException
         *                 If cache file cannot be read/written or if URL cannot be
         *                 opened.
         * @throws ParseException
         *                 If an error occurs while parsing the XML cache file.
         * @throws IllegalArgumentException
         *                 If a wrong (non-existing) currency argument was supplied.
         */
        private boolean checkCurrencyArgs(String fromCurrency, String toCurrency)
                throws IOException, ParseException, IllegalArgumentException {
            if (!isAvailable(fromCurrency))
                throw new IllegalArgumentException(fromCurrency
                        + " currency is not available.");
            if (!isAvailable(toCurrency))
                throw new IllegalArgumentException(toCurrency
                        + " currency is not available.");
            return (!fromCurrency.equals(toCurrency));
        }

        /**
         * Check whether the exchange rate for a given currency is available.
         *
         * @param currency
         *                Three letter ISO 4217 currency code of source currency.
         * @return True if exchange rate exists, false otherwise.
         */
        public boolean isAvailable(String currency) {
            return (fxRates.containsKey(currency));
        }

        /**
         * Returns currencies for which exchange rates are available.
         *
         * @return String array with ISO 4217 currency codes.
         * @throws IOException
         *                 If cache file cannot be read/written or if URL cannot be
         *                 opened.
         * @throws ParseException
         *                 If an error occurs while parsing the XML cache file.
         */
        public String[] getCurrencies() throws IOException, ParseException {
            String[] currencies = fxRates.keySet().toArray(
                    new String[fxRates.size()]);
            return currencies;
        }
    }

    private CurrencyConverter() {}

    /**
     * Returns a singleton instance of CurrencyConverter.
     * @return CurrencyConverter instance
     */
    public synchronized static CurrencyConverter getInstance() {
        if (instance == null)
            instance = new CurrencyConverter();
        return instance;
    }

    /**
     * Returns currencies for which exchange rates are available.
     *
     * @return String array with ISO 4217 currency codes.
     * @throws ParseException
     * @throws IOException
     * @throws IOException
     *                 If cache file cannot be read/written or if URL cannot be
     *                 opened.
     * @throws ParseException
     *                 If an error occurs while parsing the XML cache file.
     */
    public String[] getCurrencies() throws IOException, ParseException {
        CurrencyConversionRate rate = getRateForDate(new Date());
        if (rate != null) {
            return rate.getCurrencies();
        }
        return ArrayUtils.EMPTY_STRING_ARRAY;
    }

    private void clearConversionRates()
    {
        conversionRates.clear();
    }

    private void addConversionRate(CurrencyConversionRate rate)
    {
        if (rate != null) {
            Date date = DateUtils.truncate(rate.getReferenceDate(), Calendar.DATE);
            conversionRates.put(date, rate);
            if (startDate == null || date.before(startDate)) {
                startDate = date;
            }
            if (endDate == null || date.after(endDate)) {
                endDate = date;
            }
        }
    }

    /**
     * Check whether the exchange rate for a given date are available.
     *
     * @param origDate
     *                Date for which exchange rates are desired.
     *
     * @return True if exchange rate exists, false otherwise.
     * @throws ParseException
     * @throws IOException
     */
    public boolean haveRatesForDate(Date origDate) throws IOException, ParseException
    {
        update();

        if (origDate == null) {
            return false;
        }
        Date useDate = DateUtils.truncate(origDate, Calendar.DATE);

        return (!useDate.before(startDate) && !useDate.after(endDate));
    }

    /**
     * Convert a given date to a closest date for which we have exchange rates.
     *
     * @param origDate
     *                Date for which exchange rates are desired.
     *
     * @return date for which exchange rates are available.
     * @throws ParseException
     * @throws IOException
     */
    public Date convertDateToClosestDateWithRates(Date origDate) throws IOException, ParseException
    {
        if (haveRatesForDate(origDate)) {
            return origDate;
        } else {
            if (origDate.before(startDate)) {
                return startDate;
            }
            if (origDate.after(endDate)) {
                return endDate;
            }
        }
        return null;
    }
    /*
     * no rates published over the weekend or bank holidays
     * so use rates from closest available date (which is within the range)
     */
    private Date getDateWithRatesForDay(Date origDate) throws IllegalArgumentException, IOException, ParseException
    {
        if (origDate == null) {
            throw new IllegalArgumentException("orig date cannot be null.");
        }
        Calendar cal = Calendar.getInstance();
        cal.setTime(origDate);
        cal = DateUtils.truncate(cal, Calendar.DATE);

        Date useDate = cal.getTime();
        while (haveRatesForDate(useDate) && !conversionRates.containsKey(useDate)) {
            cal.add(Calendar.DATE, -1);
            useDate = cal.getTime();
        }

        if (haveRatesForDate(useDate)) {
            return useDate;
        }

        return null;
    }

    /*
     * given a date, get currency conversion rates that correspond to that date.
     */
    private CurrencyConversionRate getRateForDate(Date date) throws IOException, ParseException
    {
        update();

        Date useDate = getDateWithRatesForDay(date);
        if (useDate != null) {
            CurrencyConversionRate rates = conversionRates.get(useDate);
            return rates;
        }

        return null;
    }

    /**
     * Converts a long value from one currency to another. Internally long
     * values represent monetary amounts in 1/10000 of the currency unit, e.g.
     * the long value 975573l represents 97.5573 (precision = four digits after
     * comma). Using long values instead of floating point numbers prevents
     * imprecision / calculation errors resulting from floating point
     * arithmetics.
     *
     * @param date
     *                Use exchange rate for date.
     * @param amount
     *                Amount of money (in source currency) to be converted.
     * @param fromCurrency
     *                Three letter ISO 4217 currency code of source currency.
     * @param toCurrency
     *                Three letter ISO 4217 currency code of target currency.
     * @return Amount in target currency
     * @throws IOException
     *                 If cache file cannot be read/written or if URL cannot be
     *                 opened.
     * @throws ParseException
     *                 If an error occurs while parsing the XML cache file.
     * @throws IllegalArgumentException
     *                 If a wrong (non-existing) currency argument was supplied.
     */
    public long convert (long amount, String fromCurrency, String toCurrency) throws IOException, ParseException
    {
        return convert(new Date(), amount, fromCurrency, toCurrency);
    }


    /**
     * Converts a long value from one currency to another. Internally long
     * values represent monetary amounts in 1/10000 of the currency unit, e.g.
     * the long value 975573l represents 97.5573 (precision = four digits after
     * comma). Using long values instead of floating point numbers prevents
     * imprecision / calculation errors resulting from floating point
     * arithmetics. Uses conversion rate for today.
     *
     * @param amount
     *                Amount of money (in source currency) to be converted.
     * @param fromCurrency
     *                Three letter ISO 4217 currency code of source currency.
     * @param toCurrency
     *                Three letter ISO 4217 currency code of target currency.
     * @return Amount in target currency
     * @throws IOException
     *                 If cache file cannot be read/written or if URL cannot be
     *                 opened.
     * @throws ParseException
     *                 If an error occurs while parsing the XML cache file.
     * @throws IllegalArgumentException
     *                 If a wrong (non-existing) currency argument was supplied.
     */
    public long convert (Date date, long amount, String fromCurrency, String toCurrency) throws IOException, ParseException
    {
        CurrencyConversionRate rate = getRateForDate(date);
        if (rate == null) {
            throw new IllegalArgumentException(date + " rates not available for this date.");
        }
        return rate.convert(amount, fromCurrency, toCurrency);
    }

    /**
     * Converts a double precision floating point value from one currency to
     * another. Example: convert(29.95, "USD", "EUR") - converts $29.95 US Dollars
     * to Euro. Uses conversion rate for today.
     *
     * @param amount
     *                Amount of money (in source currency) to be converted.
     * @param fromCurrency
     *                Three letter ISO 4217 currency code of source currency.
     * @param toCurrency
     *                Three letter ISO 4217 currency code of target currency.
     * @return Amount in target currency
     * @throws IOException
     *                 If cache file cannot be read/written or if URL cannot be
     *                 opened.
     * @throws ParseException
     *                 If an error occurs while parsing the XML cache file.
     * @throws IllegalArgumentException
     *                 If a wrong (non-existing) currency argument was supplied.
     */
    public double convert (double amount, String fromCurrency, String toCurrency) throws IOException, ParseException
    {
        return convert(new Date(), amount, fromCurrency, toCurrency);
    }

    /**
     * Converts a double precision floating point value from one currency to
     * another. Example: convert(29.95, "USD", "EUR") - converts $29.95 US Dollars
     * to Euro.
     *
     * @param date
     *                Use exchange rate for date.
     * @param amount
     *                Amount of money (in source currency) to be converted.
     * @param fromCurrency
     *                Three letter ISO 4217 currency code of source currency.
     * @param toCurrency
     *                Three letter ISO 4217 currency code of target currency.
     * @return Amount in target currency
     * @throws IOException
     *                 If cache file cannot be read/written or if URL cannot be
     *                 opened.
     * @throws ParseException
     *                 If an error occurs while parsing the XML cache file.
     * @throws IllegalArgumentException
     *                 If a wrong (non-existing) currency argument was supplied.
     */
    public double convert (Date date, double amount, String fromCurrency, String toCurrency) throws IOException, ParseException
    {
        CurrencyConversionRate rate = getRateForDate(date);
        if (rate == null) {
            throw new IllegalArgumentException(date + " rates not available for this date.");
        }
        return rate.convert(amount, fromCurrency, toCurrency);
    }
    /**
     * Get the name of the fully qualified path name of the XML cache file. By
     * default this is a file named "ExchangeRates.xml" located in the system's
     * temporary file directory. The cache file can be shared by multiple
     * threads/applications.
     *
     * @return Path name of the XML cache file.
     */
    public String getCacheFileName() {
        return cacheFileName;
    }

    /**
     * Set the location where the XML cache file should be stored.
     *
     * @param cacheFileName
     * @see #getCacheFileName() Fully qualified path name of the XML cache file.
     */
    public void setCacheFileName(String cacheFileName) {
        this.cacheFileName = cacheFileName;
    }

    /**
     * Delete XML cache file and reset internal data structure. Calling
     * clearCache() before the convert() method forces a fresh download of the
     * currency exchange rates.
     */
    public  void clearCache() {
        initCacheFile();
        cacheFile.delete();
        cacheFile = null;
        refreshDate = null;
        startDate = null;
        endDate = null;
    }

    /**
     * Check whether cache is initialised and up-to-date. If not, re-download
     * cache file and parse data into internal data structure.
     *
     * @throws IOException
     *                 If cache file cannot be read/written or if URL cannot be
     *                 opened.
     * @throws ParseException
     *                 If an error occurs while parsing the XML cache file.
     */
    private  void update() throws IOException, ParseException {
        if (refreshDate == null) {
            initCacheFile();
            if (!cacheFile.exists()) {
                refreshCacheFile();
            }
            parse();
        }
        if (cacheIsExpired()) {
            refreshCacheFile();
            parse();
        }
    }

    /**
     * Initialises cache file member variable if not already initialised.
     */
    private void initCacheFile() {
        if (cacheFile == null) {
            if (cacheFileName == null || cacheFileName.equals(""))
                cacheFileName = System.getProperty("java.io.tmpdir")
                        + "ExchangeRates.xml";
            cacheFile = new File(cacheFileName);
        }
    }



    /**
     * Checks whether XML cache file needs to be updated. The cache file is up
     * to date for 24 hours after the reference date (plus a certain tolerance).
     * On weekends, it is 72 hours because no rates are published during
     * weekends.
     *
     * @return true if cache file needs to be updated, false otherwise.
     */
    private boolean cacheIsExpired() {
        final int tolerance = 12;
        if (refreshDate == null)
            return true;
        Calendar cal = Calendar.getInstance(TimeZone.getTimeZone("GMT"));
        long hoursOld = (cal.getTimeInMillis() - refreshDate.getTime())
                / (1000 * 60 * 60);
        int hoursValid = 24 + tolerance;
        cal.setTime(refreshDate);
        if (cal.get(Calendar.DAY_OF_WEEK) == Calendar.FRIDAY)
            hoursValid = 72;
        else if (cal.get(Calendar.DAY_OF_WEEK) == Calendar.SATURDAY)
            hoursValid = 48; // hypothetical... rates are never published on
        // Saturdays
        if (hoursOld > hoursValid)
            return true;
        return false;
    }

    /**
     * (Re-) download the XML cache file and store it in a temporary location.
     *
     * @throws IOException
     *                 If (1) URL cannot be opened, or (2) if cache file cannot
     *                 be opened, or (3) if a read/write error occurs.
     */
    private  void refreshCacheFile() throws IOException {
        lastError = null;
        initCacheFile();
        InputStreamReader in;
        FileWriter out;
        try {
            URL ecbRates = new URL(ecbRatesURL);
            in = new InputStreamReader(ecbRates.openStream());
            out = new FileWriter(cacheFile);
            try {
                int c;
                while ((c = in.read()) != -1)
                    out.write(c);
            } catch (IOException e) {
                lastError = "Read/Write Error: " + e.getMessage();
            } finally {
                out.flush();
                out.close();
                in.close();
            }
        } catch (IOException e) {
            lastError = "Connection/Open Error: " + e.getMessage();
        }
        if (lastError != null) {
            throw new IOException(lastError);
        }
    }

    /**
     * Convert a numeric string to a long value with a precision of four digits
     * after the decimal point without rounding. E.g. "123.456789" becomes
     * 1234567l.
     *
     * @param str
     *                Positive numeric string expression.
     * @return Value representing 1/10000th of a currency unit.
     * @throws NumberFormatException
     *                 If "str" argument is not numeric.
     */
    private long stringToLong(String str) throws NumberFormatException {
        int decimalPoint = str.indexOf('.');
        String wholePart = "";
        String fractionPart = "";
        if (decimalPoint > -1) {
            if (decimalPoint > 0)
                wholePart = str.substring(0, decimalPoint);
            fractionPart = str.substring(decimalPoint + 1);
            String padString = "0000";
            int padLength = 4 - fractionPart.length();
            if (padLength > 0)
                fractionPart += padString.substring(0, padLength);
            else if (padLength < 0)
                fractionPart = fractionPart.substring(0, 4);
        } else {
            wholePart = str;
            fractionPart = "0000";
        }
        return (Long.parseLong(wholePart + fractionPart));
    }



    /**
     * Parse XML cache file and create internal data structures containing
     * exchange rates and reference dates.
     *
     * @throws ParseException
     *                 If XML file cannot be parsed.
     */
    private  void parse() throws ParseException {
        try {
            FileReader input = new FileReader(cacheFile);
            System.out.println("parsing file: " + cacheFile);
            XMLReader saxReader = XMLReaderFactory.createXMLReader();
            DefaultHandler handler = new DefaultHandler() {
                CurrencyConversionRate ratesForDay = null;
                public void startElement(String uri, String localName,
                        String qName, Attributes attributes) {
                    if (localName.equals("Cube")) {
                        String date = attributes.getValue("time");
                        if (date != null) {
                            SimpleDateFormat df = new SimpleDateFormat(
                                    "yyyy-MM-dd HH:mm z");
                            try {
                                ratesForDay = new CurrencyConversionRate();
                                ratesForDay.setReferenceDate(df.parse(date + " 14:15 CET"));
                                ratesForDay.getFxRates().put("EUR", 10000L);
                                addConversionRate(ratesForDay);
                            } catch (ParseException e) {
                                lastError = "Cannot parse reference date: "
                                        + date;
                            }
                        }
                        String currency = attributes.getValue("currency");
                        String rate = attributes.getValue("rate");
                        if (currency != null && rate != null && ratesForDay != null && ratesForDay.getReferenceDate() != null) {
                            try {
                                ratesForDay.getFxRates().put(currency, stringToLong(rate));
                            } catch (Exception e) {
                                lastError = "Cannot parse exchange rate: "
                                        + rate + ". " + e.getMessage();
                            }
                        }
                    }
                }
            };
            lastError = null;
            clearConversionRates();
            startDate = null;
            endDate = null;
            saxReader.setContentHandler(handler);
            saxReader.setErrorHandler(handler);
            saxReader.parse(new InputSource(input));
            refreshDate = new Date();
            input.close();
        } catch (Exception e) {
            lastError = "Parser error: " + e.getMessage();
        }
        if (lastError != null) {
            throw new ParseException(lastError, 0);
        }
    }


    public static void main(String[] args) throws IOException, ParseException
    {
        System.out.println("*** Simple tests of this class ****");
        CurrencyConverter converter = CurrencyConverter.getInstance();
        for (String currency : converter.getCurrencies()) {
            System.out.println("Currency: " + currency);
        }
        Date date = new Date();
        double amount = 50;
        double converted;
        String fromCurrency = "USD";
        String toCurrency = "EUR";

        converted = converter.convert(date, amount, fromCurrency, toCurrency);
        System.out.println(date + ": " + amount + " " + fromCurrency + " = " + converted + " " + toCurrency);

        toCurrency = "GBP";
        converted = converter.convert(date, amount, fromCurrency, toCurrency);
        System.out.println(date + ": " + amount + " " + fromCurrency + " = " + converted + " " + toCurrency);

        try {
            date = new Date(date.getTime() +  2 * DateUtils.MILLIS_PER_DAY);
            toCurrency = "EUR";

            converted = converter.convert(date, amount, fromCurrency, toCurrency);
            System.out.println(date + ": " + amount + " " + fromCurrency + " = " + converted + " " + toCurrency);

            toCurrency = "GBP";
            converted = converter.convert(date, amount, fromCurrency, toCurrency);
            System.out.println(date + ": " + amount + " " + fromCurrency + " = " + converted + " " + toCurrency);
        }
        catch (IllegalArgumentException e) {
            System.out.println("Unable to convert rates for date: " + date);
            date = converter.convertDateToClosestDateWithRates(date);
            System.out.println("Use this date instead: " + date);
        }

        date = new Date(date.getTime() - 30 * DateUtils.MILLIS_PER_DAY);

        toCurrency = "EUR";
        converted = converter.convert(date, amount, fromCurrency, toCurrency);
        System.out.println(date + ": " + amount + " " + fromCurrency + " = " + converted + " " + toCurrency);

        toCurrency = "GBP";
        converted = converter.convert(date, amount, fromCurrency, toCurrency);
        System.out.println(date + ": " + amount + " " + fromCurrency + " = " + converted + " " + toCurrency);

        Date firstDay = new Date(0);
        if (!converter.haveRatesForDate(firstDay)) {
            System.out.println("No rates for date: " + firstDay);
            Date newDate = converter.convertDateToClosestDateWithRates(firstDay);
            System.out.println("Converted to date: " + newDate);
        }
    }
}
