// © 2020 and later: Unicode, Inc. and others.
// License & terms of use: http://www.unicode.org/copyright.html
package com.ibm.icu.impl.units;

import java.math.BigDecimal;
import java.math.BigInteger;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

import com.ibm.icu.impl.number.DecimalQuantity;
import com.ibm.icu.impl.number.DecimalQuantity_DualStorageBCD;
import com.ibm.icu.number.Precision;
import com.ibm.icu.util.Measure;

/**
 * Converts from single or compound unit to single, compound or mixed units. For example, from {@code meter}
 * to {@code foot+inch}.
 * <p>
 * DESIGN: This class uses <code>UnitsConverter</code> in order to perform the single converter (i.e. converters from
 * a single unit to another single unit). Therefore, <code>ComplexUnitsConverter</code> class contains multiple
 * instances of the <code>UnitsConverter</code> to perform the conversion.
 */
public class ComplexUnitsConverter {
    public static final BigDecimal EPSILON = BigDecimal.valueOf(Math.ulp(1.0));
    public static final BigDecimal EPSILON_MULTIPLIER = BigDecimal.valueOf(1).add(EPSILON);

    // TODO(ICU-21937): Make it private after submitting the public units conversion API.
    public ArrayList<UnitsConverter> unitsConverters_;
    /**
     * Individual units of mixed units, sorted big to small, with indices
     * indicating the requested output mixed unit order.
     */
    // TODO(ICU-21937): Make it private after submitting the public units conversion API.
    public List<MeasureUnitImpl.MeasureUnitImplWithIndex> units_;
    private MeasureUnitImpl inputUnit_;

    /**
     * Constructs <code>ComplexUnitsConverter</code> for an <code>inputUnit</code> that could be Single, Compound or
     * Mixed. In case of: 1- Single and Compound units, the conversion will not perform anything, the input will be
     * equal to the output. 2- Mixed Unit the conversion will consider the input in the biggest unit. and will convert
     * it to be spread throw the input units. For example: if input unit is "inch-and-foot", and the input is 2.5. The
     * converter will consider the input value in "foot", because foot is the biggest unit. Then, it will convert 2.5
     * feet to "inch-and-foot".
     *
     * @param targetUnit
     *            represents the input unit. could be any type. (single, compound or mixed).
     */
    public ComplexUnitsConverter(MeasureUnitImpl targetUnit, ConversionRates conversionRates) {
        this.units_ = targetUnit.extractIndividualUnitsWithIndices();
        assert (!this.units_.isEmpty());

        // Assign the biggest unit to inputUnit_.
        this.inputUnit_ = this.units_.get(0).unitImpl;
        MeasureUnitImpl.MeasureUnitImplComparator comparator = new MeasureUnitImpl.MeasureUnitImplComparator(
                conversionRates);
        for (MeasureUnitImpl.MeasureUnitImplWithIndex unitWithIndex : this.units_) {
            if (comparator.compare(unitWithIndex.unitImpl, this.inputUnit_) > 0) {
                this.inputUnit_ = unitWithIndex.unitImpl;
            }
        }

        this.init(conversionRates);
    }

    /**
     * Constructs <code>ComplexUnitsConverter</code> NOTE: - inputUnit and outputUnits must be under the same category -
     * e.g. meter to feet and inches --> all of them are length units.
     *
     * @param inputUnitIdentifier
     *              represents the source unit identifier. (should be single or compound unit).
     * @param outputUnitsIdentifier
     *              represents the output unit identifier. could be any type. (single, compound or mixed).
     */
    public ComplexUnitsConverter(String inputUnitIdentifier, String outputUnitsIdentifier) {
        this(
                MeasureUnitImpl.forIdentifier(inputUnitIdentifier),
                MeasureUnitImpl.forIdentifier(outputUnitsIdentifier),
                new ConversionRates()
        );
    }

    /**
     * Constructs <code>ComplexUnitsConverter</code> NOTE: - inputUnit and outputUnits must be under the same category -
     * e.g. meter to feet and inches --> all of them are length units.
     *
     * @param inputUnit
     *            represents the source unit. (should be single or compound unit).
     * @param outputUnits
     *            represents the output unit. could be any type. (single, compound or mixed).
     * @param conversionRates
     *            a ConversionRates instance containing the unit conversion rates.
     */
    public ComplexUnitsConverter(MeasureUnitImpl inputUnit, MeasureUnitImpl outputUnits,
            ConversionRates conversionRates) {
        this.inputUnit_ = inputUnit;
        this.units_ = outputUnits.extractIndividualUnitsWithIndices();
        assert (!this.units_.isEmpty());

        this.init(conversionRates);
    }

    /**
     * Sorts units_, which must be populated before calling this, and populates
     * unitsConverters_.
     */
    private void init(ConversionRates conversionRates) {
        // Sort the units in a descending order.
        Collections.sort(this.units_,
                Collections.reverseOrder(new MeasureUnitImpl.MeasureUnitImplWithIndexComparator(conversionRates)));

        // If the `outputUnits` is `UMEASURE_UNIT_MIXED` such as `foot+inch`. Thus means there is more than one unit
        //  and In this case we need more converters to convert from the `inputUnit` to the first unit in the
        //  `outputUnits`. Then, a converter from the first unit in the `outputUnits` to the second unit and so on.
        //      For Example:
        //          - inputUnit is `meter`
        //          - outputUnits is `foot+inch`
        //              - Therefore, we need to have two converters:
        //                      1. a converter from `meter` to `foot`
        //                      2. a converter from `foot` to `inch`
        //          - Therefore, if the input is `2 meter`:
        //              1. convert `meter` to `foot` --> 2 meter to 6.56168 feet
        //              2. convert the residual of 6.56168 feet (0.56168) to inches, which will be (6.74016
        //              inches)
        //              3. then, the final result will be (6 feet and 6.74016 inches)
        unitsConverters_ = new ArrayList<>();
        for (int i = 0, n = units_.size(); i < n; i++) {
            if (i == 0) { // first element
                unitsConverters_.add(new UnitsConverter(this.inputUnit_, units_.get(i).unitImpl, conversionRates));
            } else {
                unitsConverters_
                        .add(new UnitsConverter(units_.get(i - 1).unitImpl, units_.get(i).unitImpl, conversionRates));
            }
        }
    }

    /**
     * Returns true if the specified {@code quantity} of the {@code inputUnit}, expressed in terms of the biggest
     * unit in the MeasureUnit {@code outputUnit}, is greater than or equal to {@code limit}.
     * <p>
     * For example, if the input unit is {@code meter} and the target unit is {@code foot+inch}. Therefore,
     * this function will convert the {@code quantity} from {@code meter} to {@code foot}, then, it will
     * compare the value in {@code foot} with the {@code limit}.
     */
    public boolean greaterThanOrEqual(BigDecimal quantity, BigDecimal limit) {
        assert !units_.isEmpty();

        // NOTE: First converter converts to the biggest quantity.
        return unitsConverters_.get(0).convert(quantity).multiply(EPSILON_MULTIPLIER).compareTo(limit) >= 0;
    }

    public static class ComplexConverterResult {
        public final int indexOfQuantity;
        public final List<Measure> measures;

        ComplexConverterResult(int indexOfQuantity, List<Measure> measures) {
            this.indexOfQuantity = indexOfQuantity;
            this.measures = measures;
        }
    }

    /**
     * Returns outputMeasures which is an array with the corresponding values.
     * - E.g. converting meters to feet and inches.
     * 1 meter --> 3 feet, 3.3701 inches
     * NOTE:
     * the smallest element is the only element that could have fractional values. And all
     * other elements are floored to the nearest integer
     */
    public ComplexConverterResult convert(BigDecimal quantity, Precision rounder) {
        BigInteger sign = BigInteger.ONE;
        if (quantity.compareTo(BigDecimal.ZERO) < 0 && unitsConverters_.size() > 1) {
            quantity = quantity.abs();
            sign = sign.negate();
        }

        // For N converters:
        // - the first converter converts from the input unit to the largest
        //   unit,
        // - N-1 converters convert to bigger units for which we want integers,
        // - the Nth converter (index N-1) converts to the smallest unit, which
        //   isn't (necessarily) an integer.
        List<BigInteger> intValues = new ArrayList<>(unitsConverters_.size() - 1);
        for (int i = 0, n = unitsConverters_.size(); i < n; ++i) {
            quantity = (unitsConverters_.get(i)).convert(quantity);

            if (i < n - 1) {
                // The double type has 15 decimal digits of precision. For choosing
                // whether to use the current unit or the next smaller unit, we
                // therefore nudge up the number with which the thresholding
                // decision is made. However after the thresholding, we use the
                // original values to ensure unbiased accuracy (to the extent of
                // double's capabilities).
                BigInteger flooredQuantity = quantity.multiply(EPSILON_MULTIPLIER).setScale(0, RoundingMode.FLOOR).toBigInteger();
                intValues.add(flooredQuantity);

                // Keep the residual of the quantity.
                // For example: `3.6 feet`, keep only `0.6 feet`
                BigDecimal remainder = quantity.subtract(BigDecimal.valueOf(flooredQuantity.longValue()));
                if (remainder.compareTo(BigDecimal.ZERO) == -1) {
                    quantity = BigDecimal.ZERO;
                } else {
                    quantity = remainder;
                }
            }
        }

        quantity = applyRounder(intValues, quantity, rounder);

        // Initialize empty measures.
        List<Measure> measures = new ArrayList<>(unitsConverters_.size());
        for (int i = 0; i < unitsConverters_.size(); i++) {
            measures.add(null);
        }

        // Package values into Measure instances in measures:
        int indexOfQuantity = -1;
        for (int i = 0, n = unitsConverters_.size(); i < n; ++i) {
            if (i < n - 1) {
                Measure measure = new Measure(intValues.get(i).multiply(sign), units_.get(i).unitImpl.build());
                measures.set(units_.get(i).index, measure);
            } else {
                indexOfQuantity = units_.get(i).index;
                Measure measure =
                        new Measure(quantity.multiply(BigDecimal.valueOf(sign.longValue())),
                                units_.get(i).unitImpl.build());
                measures.set(indexOfQuantity, measure);
            }
        }

        return new ComplexConverterResult(indexOfQuantity , measures);
    }

    /**
     * Applies the rounder to the quantity (last element) and bubble up any carried value to all the intValues.
     *
     * @return the rounded quantity
     */
    private BigDecimal applyRounder(List<BigInteger> intValues, BigDecimal quantity, Precision rounder) {
        if (rounder == null) {
            return quantity;
        }

        DecimalQuantity quantityBCD = new DecimalQuantity_DualStorageBCD(quantity);
        rounder.apply(quantityBCD);
        quantity = quantityBCD.toBigDecimal();

        if (intValues.size() == 0) {
            // There is only one element, Therefore, nothing to be done
            return quantity;
        }

        // Check if there's a carry, and bubble it back up the resulting intValues.
        int lastIndex = unitsConverters_.size() - 1;
        BigDecimal carry = unitsConverters_.get(lastIndex).convertInverse(quantity).multiply(EPSILON_MULTIPLIER)
                .setScale(0, RoundingMode.FLOOR);
        if (carry.compareTo(BigDecimal.ZERO) <= 0) { // carry is not greater than zero
            return quantity;
        }
        quantity = quantity.subtract(unitsConverters_.get(lastIndex).convert(carry));
        intValues.set(lastIndex - 1, intValues.get(lastIndex - 1).add(carry.toBigInteger()));

        // We don't use the first converter: that one is for the input unit
        for (int j = lastIndex - 1; j > 0; j--) {
            carry = unitsConverters_.get(j)
                    .convertInverse(BigDecimal.valueOf(intValues.get(j).longValue()))
                    .multiply(EPSILON_MULTIPLIER)
                    .setScale(0, RoundingMode.FLOOR);
            if (carry.compareTo(BigDecimal.ZERO) <= 0) { // carry is not greater than zero
                break;
            }
            intValues.set(j, intValues.get(j).subtract(unitsConverters_.get(j).convert(carry).toBigInteger()));
            intValues.set(j - 1, intValues.get(j - 1).add(carry.toBigInteger()));
        }

        return quantity;
    }

    @Override
    public String toString() {
        return "ComplexUnitsConverter [unitsConverters_=" + unitsConverters_ + ", units_=" + units_ + "]";
    }
}
