import pytest
from pytest import approx

from barril import units
from barril.basic.format_float import FormatFloat
from barril.basic.fraction import FractionValue
from barril.units import Array
from barril.units import FixedArray
from barril.units import InvalidQuantityTypeError
from barril.units import InvalidUnitError
from barril.units import ObtainQuantity
from barril.units import Quantity
from barril.units import UnitsError
from barril.units import UNKNOWN_QUANTITY_TYPE
from barril.units import UNKNOWN_UNIT
from barril.units.exceptions import QuantityValidationError
from barril.units.unit_database import UnitDatabase


def testNotDefaultUnitDatabase() -> None:
    unit_database = units.UnitDatabase()
    with pytest.raises(AssertionError):
        unit_database.CheckDefaultUnitDatabase()


def testDefaultUnitDatabase(unit_database) -> None:
    unit_database.CheckDefaultUnitDatabase()


def testConv(unit_database_empty) -> None:
    unit_database = unit_database_empty

    # add some units for testing
    unit_database.AddUnitBase("temperature", "Celcius", "ºC")
    unit_database.AddUnit("temperature", "Fahrenheit", "F", "%f * 1.8 + 32.0", " (%f - 32.0) / 1.8")
    unit_database.AddUnit("temperature", "Kelvin", "K", "%f + 273.15", "%f - 273.15")
    unit_database.AddUnit(
        "temperature", "Rakini", "R", "%f * 1.8 + 32 + 459.67", "(%f - 32 - 459.67) / 1.8"
    )

    assert 50 == unit_database.Convert("temperature", "ºC", "F", 10)
    assert 10 == unit_database.Convert("temperature", "F", "ºC", 50)
    assert approx(abs(-245.372222 - unit_database.Convert("temperature", "R", "ºC", 50)), 5) == 0
    assert approx(abs(581.67 - unit_database.Convert("temperature", "ºC", "R", 50)), 7) == 0

    assert [50, 50, 50] == unit_database.Convert("temperature", "ºC", "F", [10, 10, 10])
    assert [10, 10, 10] == unit_database.Convert("temperature", "F", "ºC", [50, 50, 50])
    assert (10, 10, 10) == unit_database.Convert("temperature", "F", "ºC", (50, 50, 50))


def testPrecision() -> None:
    unit_database = units.UnitDatabase()
    unit_database.AddUnitBase("Compressibility", "1/Bars", "1/Bars")
    unit_database.AddUnit(
        "Compressibility", "1/Psi", "1/Psi", frombase="%f / 14.50377", tobase="%f * 14.50377"
    )
    unit_database.AddUnit(
        "Compressibility", "1/Atm", "1/Atm", frombase="%f / 0.986923", tobase="%f * 0.986923"
    )

    assert (
        approx(
            abs(
                14.50377 * 10e-6
                - unit_database.Convert("Compressibility", "1/Psi", "1/Bars", 1 * 10e-6)
            ),
            5,
        )
        == 0
    )
    assert (
        approx(
            abs(
                0.000063816588
                - unit_database.Convert("Compressibility", "1/Psi", "1/Bars", 4.4e-006)
            ),
            5,
        )
        == 0
    )


def testQuantityTypes(unit_database_custom_conversion) -> None:
    unit_database = unit_database_custom_conversion
    real = unit_database.GetQuantityTypes()
    real.sort()
    assert real == [UNKNOWN_QUANTITY_TYPE, "length", "temperature"]


def testUnitQuantityType(unit_database_custom_conversion) -> None:
    unit_database = unit_database_custom_conversion
    assert "length" == unit_database.GetQuantityType("m")


def testDistanceUnits(unit_database_custom_conversion) -> None:
    unit_database = unit_database_custom_conversion
    available_units = ["m", "mm", "cm", "km", "mi", "in", "µm"]
    real = unit_database.GetUnits("length")
    assert available_units == real


def testFindCase(unit_database_custom_conversion) -> None:
    unit_database = units.UnitDatabase()
    # add some units for testing
    unit_database.AddUnitBase("temperature", "mang1", "mA")
    unit_database.AddUnit("temperature", "mang2", "Ma", frombase="%f", tobase="%f")
    unit_database.AddUnit("temperature", "Celsius", "C", frombase="%f", tobase="%f")
    unit_database.AddUnit("temperature", "mang3", "MA", frombase="%f", tobase="%f")

    unit_database.AddCategory("my", "temperature")
    with pytest.raises(AssertionError):
        unit_database.FindUnitCase("my", "ma")
    assert "C" == unit_database.FindUnitCase("my", "c")
    assert "C" == unit_database.FindUnitCase("my", "C")


def testUnits(unit_database_custom_conversion) -> None:
    unit_database = unit_database_custom_conversion
    real = unit_database.GetUnits()
    real.sort()
    available_units = [UNKNOWN_UNIT, "m", "mm", "cm", "km", "mi", "in", "µm", "ºC", "F", "K"]
    available_units.sort()
    assert available_units == real


def testBaseUnit(unit_database_custom_conversion) -> None:
    unit_database = unit_database_custom_conversion
    assert "m" == unit_database.GetBaseUnit("length")


def testUnitNames(unit_database_custom_conversion) -> None:
    unit_database = unit_database_custom_conversion
    names = {"meters", "milimeters", "centimeters", "kilometers", "miles", "inches", "micrometers"}
    onames = set(unit_database.GetUnitNames("length"))
    assert names == onames


def testClear(unit_database_custom_conversion) -> None:
    unit_database = unit_database_custom_conversion
    unit_database.Clear()
    with pytest.raises(units.InvalidQuantityTypeError):
        unit_database.CheckQuantityType("length")


def testCategory(unit_database_custom_conversion) -> None:
    unit_database = unit_database_custom_conversion
    unit_database.AddCategory(
        "my length",
        "length",
        ["mm", "m"],
        default_unit="m",
        default_value=15.5,
        min_value=-6e-10,
        max_value=2e5,
        is_min_exclusive=True,
        is_max_exclusive=False,
        caption="My Length",
    )

    assert unit_database.GetDefaultValue("my length") == 15.5
    assert unit_database.GetDefaultUnit("my length") == "m"
    assert sorted(list(unit_database.IterCategories())) == ["length", "my length"]

    quantity = ObtainQuantity("m", "my length")
    formatted_value = FormatFloat("%g", -6e-010)
    with pytest.raises(
        QuantityValidationError,
        match="Invalid value for My Length: %s. Must be > %s." % (formatted_value, formatted_value),
    ) as exc_info:
        quantity.CheckValue(-6e-10)
    e = exc_info.value
    assert e.message == "Invalid value for My Length: {}. Must be > {}.".format(
        formatted_value, formatted_value
    )
    assert e.caption == "My Length"
    assert e.value == float(formatted_value)
    assert e.operator == ">"
    assert e.value == float(formatted_value)

    quantity.CheckValue(0)  # without specifying unit
    mm_quantity = ObtainQuantity("mm", "my length")
    mm_quantity.CheckValue(2e5)

    with pytest.raises(
        QuantityValidationError, match="Invalid value for My Length: 200000. Must be <= 200000.0."
    ):
        mm_quantity.CheckValue(2e8 + 1)

    # Check the unit info using a category instead a quantity_type
    with pytest.raises(InvalidQuantityTypeError):
        unit_database.GetInfo("unknown length", "mm")

    unit_database.GetInfo("my length", "mm")
    unit_database.GetInfo(UNKNOWN_QUANTITY_TYPE, "I know it is m3", fix_unknown=True)


def testUniqueness(unit_database_custom_conversion) -> None:
    # trying to re-register milimeters (mm)
    unit_database = unit_database_custom_conversion
    with pytest.raises(RuntimeError):
        unit_database.AddUnit("length", "milimeters", "mm", "%f * 1000.0", "%f / 1000.0")


def testDefaultUnitWhenNoneIsPassed(unit_database_custom_conversion) -> None:
    unit_database = unit_database_custom_conversion
    unit_database.AddCategory("my category", "length", ["mm"], default_unit="mm")
    # Quantity without specifying unit, must use category's default unit and not raise
    default_when_none = ObtainQuantity(None, "my category")
    assert ObtainQuantity("mm", "my category") == default_when_none


def testDefaultUnitValid(unit_database_custom_conversion) -> None:
    """
    Test the situation where a category does not have the base unit among its valid units
    """
    unit_database = unit_database_custom_conversion
    unit_database.AddCategory("my category", "length", ["mm"])
    assert unit_database.GetCategoryInfo("my category").default_unit == "mm"


def testConvertionWithExponent(unit_database_custom_conversion) -> None:
    """
    Test conversions with different exponents.
    """
    unit_database = unit_database_custom_conversion
    assert approx(abs(100 - unit_database.Convert("length", [("m", 1)], [("cm", 1)], 1)), 5) == 0
    assert approx(abs(10000 - unit_database.Convert("length", [("m", 2)], [("cm", 2)], 1)), 5) == 0

    # Doesn't make sense changing the exponent in the from and to
    with pytest.raises(ValueError):
        unit_database.Convert("length", [("m", 2)], [("m", 1)], 1)


def testAddCategory(unit_database_custom_conversion) -> None:
    """
    Testing if AddCategory gives an error if trying to register twice.
    """
    unit_database = unit_database_custom_conversion
    unit_database.AddCategory(
        "my category", "length", valid_units=["mm", "m", "cm"], default_unit="mm"
    )
    with pytest.raises(UnitsError):
        unit_database.AddCategory("my category", "length", ["mm"], default_unit="mm")

    unit_database.AddCategory("derived category", "length")

    # Create a preferred unit set for categories
    assert unit_database.GetValidUnits("my category") == ["mm", "m", "cm"]
    assert unit_database.GetValidUnits("derived category") == ["m", "mm", "cm", "km"]


def testValidUnits(unit_database_custom_conversion) -> None:
    """
    Test while registering a category that passing None as valid_units returns the
    units of the quantity type.
    """
    unit_database = unit_database_custom_conversion
    unit_database.AddCategory("length", "length", override=True)
    info = unit_database.AddCategory("my category", "length", default_unit="mm")
    length_units = unit_database.GetUnits("length")
    assert info.valid_units is None
    assert unit_database.GetValidUnits("my category") == length_units


def testConvertQuantityTypeCheck(unit_database_custom_conversion) -> None:
    """
    Check for bug when trying to convert a unit to the same unit, but passing
    an invalid quantity type as argument.
    """
    unit_database = unit_database_custom_conversion
    with pytest.raises(InvalidQuantityTypeError):
        unit_database.Convert("XXX", "m", "mm", 100)
    with pytest.raises(InvalidUnitError):
        unit_database.Convert("length", "XXX", "m", 100)
    with pytest.raises(InvalidUnitError):
        unit_database.Convert("length", "m", "XXX", 100)


def testCheckValidUnits(unit_database_custom_conversion) -> None:
    """
    Make sure we check if the given valid_units are actually valid for that quantity_type
    """
    unit_database = unit_database_custom_conversion
    with pytest.raises(ValueError):
        unit_database.AddCategory("my category", "length", default_unit="mm", valid_units="foooo")


def testDiscoverCloseUnitMatches() -> None:
    unit_database = UnitDatabase.CreateDefaultSingleton()
    assert unit_database.FindSimilarUnitMatches("kg/day") == ["kg/d"]
    assert unit_database.FindSimilarUnitMatches("bbls/d") == ["bbl/d", "bbl/d2"]
    assert unit_database.FindSimilarUnitMatches("mg/l") == ["mg/L"]


def testDefaultCaption() -> None:
    unit_database = UnitDatabase.CreateDefaultSingleton()
    category_info = unit_database.GetCategoryInfo("angle per volume")
    assert category_info.caption == "Angle per Volume"


def testAddCategoryBasedOnCategory(unit_database_custom_conversion) -> None:
    unit_database = unit_database_custom_conversion
    unit_database.AddCategory(
        "my category", "length", valid_units=["mm", "m"], default_unit="mm", default_value=0.5
    )

    unit_database.AddCategory("my category 2", from_category="my category", valid_units=["mm"])
    assert unit_database.GetValidUnits("my category 2") == ["mm"]

    unit_database.AddCategory("my category 3", from_category="my category", default_unit="m")
    assert unit_database.GetDefaultUnit("my category 3") == "m"

    unit_database.AddCategory("my category 4", from_category="my category", default_value=0.8)
    assert unit_database.GetDefaultValue("my category 4") == 0.8
    assert unit_database.GetValidUnits("my category 4") == ["mm", "m"]


def testConvertFractionValues(unit_database_custom_conversion) -> None:
    """
    Test unit database handling fraction values.
    """
    db = unit_database_custom_conversion

    # Converting half meter to cm
    value = FractionValue(0, (1, 2))

    value = db.Convert("length", "m", "cm", value)
    assert float(value) == 50


def testRegisterTwoFunctionsForTheSameClass(unit_database_custom_conversion) -> None:
    """
    Test the behavior when we attempt to register two convert functions for the same class
    """

    def ConvertFunction1(*args, **kwargs):
        return 0

    db = unit_database_custom_conversion
    # There is a function specialized in convert fraction values. Attempting to register another
    # function should raise an error

    value = FractionValue(0, (1, 2))
    db.Convert("length", "m", "cm", value)

    with pytest.raises(AssertionError):
        db.RegisterAdditionalConversionType(FractionValue, ConvertFunction1)


def testNumpyConversion(unit_database_custom_conversion) -> None:
    unit_database = unit_database_custom_conversion

    from barril.units.posc import MakeBaseToCustomary, MakeCustomaryToBase

    f_unit_to_base = MakeCustomaryToBase(273.15, 1, 1, 0)
    f_base_to_unit = MakeBaseToCustomary(273.15, 1, 1, 0)
    unit_database.AddUnit(
        "My Temperature",
        "degrees Celsius",
        "degC",
        f_base_to_unit,
        f_unit_to_base,
        default_category=None,
    )
    f_unit_to_base = MakeCustomaryToBase(2298.35, 5, 9, 0)
    f_base_to_unit = MakeBaseToCustomary(2298.35, 5, 9, 0)
    unit_database.AddUnit(
        "My Temperature",
        "degree Fahrenheit",
        "degF",
        f_base_to_unit,
        f_unit_to_base,
        default_category=None,
    )
    f_unit_to_base = MakeCustomaryToBase(0, 1, 1, 0)
    f_base_to_unit = MakeBaseToCustomary(0, 1, 1, 0)
    unit_database.AddUnit(
        "My Temperature", "my Kelvin", "myK", f_base_to_unit, f_unit_to_base, default_category=None
    )

    unit_database.AddCategory(
        "My Temperature",
        quantity_type="My Temperature",
        valid_units=["myK", "degC", "degF"],
        default_unit="myK",
        default_value=2,
        min_value=0,
        max_value=15,
        is_min_exclusive=False,
        is_max_exclusive=False,
        caption="My Temperature",
    )

    import numpy

    values = numpy.array(list(range(10, 13)), numpy.float32)
    arr = Array("My Temperature", values, "degC")
    obtained = arr.GetValues("degF")

    expected = [49.99995041, 51.79994965, 53.5999527]
    assert approx(obtained) == expected


def testNumpyWithMinMax(unit_database_custom_conversion) -> None:
    unit_database = unit_database_custom_conversion
    unit_database.AddCategory(
        "my length",
        "length",
        ["mm", "m"],
        default_unit="m",
        default_value=2,
        min_value=0,
        max_value=15,
        is_min_exclusive=False,
        is_max_exclusive=False,
        caption="My Length",
    )
    import numpy

    values = numpy.array(list(range(10)), numpy.float32)
    Array("my length", values, "mm")

    values = numpy.array(list(range(20000, 20010)), numpy.float32)
    # Raise validation error
    another = Array("my length", values, "mm")
    assert not another.IsValid()
    with pytest.raises(ValueError):
        another.CheckValidity()

    # Don't raise validation error if validate = False.
    arr = Array("my length", values, "mm")
    assert not arr.IsValid()

    another = arr.CreateCopy(values=values, unit="mm")
    assert not another.IsValid()

    # Same checks on FixedArray.
    values = numpy.array(list(range(10)), numpy.float32)
    FixedArray(len(values), "my length", values, "mm")

    values = numpy.array(list(range(20000, 20010)), numpy.float32)
    # Raise validation error
    another = FixedArray(len(values), "my length", values, "mm")
    assert not another.IsValid()

    # Don't raise validation error if validate = False.
    arr = FixedArray(len(values), "my length", values, "mm")
    assert not arr.IsValid()

    another = arr.CreateCopy(values=values, unit="mm")
    assert not another.IsValid()


def testInvalidUnitCategoryDoesntGenerateError(unit_database_custom_conversion) -> None:
    unit_database = unit_database_custom_conversion
    with pytest.raises(ValueError):
        unit_database.AddCategory(
            "my invalid length",
            "length",
            caption="Invalid length",
            # This is not a valid unit
            default_unit="Mm",
            min_value=0,
            max_value=99999.0,
            is_min_exclusive=False,
            is_max_exclusive=False,
            valid_units=["mm", "m"],
        )


def testGetValidUnitsEmptyQuantity(unit_database_custom_conversion) -> None:
    """
    Test that retrieving the valid units for the category of the empty quantity (as created
    by Quantity.CreateEmpty() correctly returns an empty list.
    """
    unit_database = unit_database_custom_conversion
    quantity = Quantity.CreateEmpty()
    assert unit_database.GetValidUnits(quantity.GetCategory()) == []
