# Licensed under a 3-clause BSD style license - see LICENSE.rst

"""
Unit tests for the handling of physical types in `astropy.units`.
"""
import pickle

import pytest

from astropy import units as u
from astropy.constants import hbar
from astropy.units import physical
from astropy.utils.exceptions import AstropyDeprecationWarning

unit_physical_type_pairs = [
    (u.m, "length"),
    (u.cm**3, "volume"),
    (u.km / u.h, "speed"),
    (u.barn * u.Mpc, "volume"),
    (u.m * u.s**8, "unknown"),
    (u.m / u.m, "dimensionless"),
    (hbar.unit, "angular momentum"),
    (u.erg / (u.cm**2 * u.s * u.AA), "spectral flux density wav"),
    (u.photon / (u.cm**2 * u.s * u.AA), "photon flux density wav"),
    (u.photon / (u.cm**2 * u.s * u.Hz), "photon flux density"),
    (u.byte, "data quantity"),
    (u.bit, "data quantity"),
    (u.imperial.mi / u.week, "speed"),
    (u.erg / u.s, "power"),
    (u.C / u.s, "electrical current"),
    (u.C / u.s / u.cm**2, "electrical current density"),
    (u.T * u.m**2, "magnetic flux"),
    (u.N * u.m, "energy"),
    (u.rad / u.ms, "angular speed"),
    (u.Unit(1), "dimensionless"),
    (u.m**2, "area"),
    (u.s, "time"),
    (u.rad, "angle"),
    (u.sr, "solid angle"),
    (u.m / u.s**2, "acceleration"),
    (u.Hz, "frequency"),
    (u.g, "mass"),
    (u.mol, "amount of substance"),
    (u.K, "temperature"),
    (u.deg_C, "temperature"),
    (u.imperial.deg_F, "temperature"),
    (u.imperial.deg_R, "temperature"),
    (u.imperial.deg_R / u.m, "temperature_gradient"),
    (u.N, "force"),
    (u.J, "energy"),
    (u.Pa, "pressure"),
    (u.W, "power"),
    (u.kg / u.m**3, "mass density"),
    (u.m**3 / u.kg, "specific volume"),
    (u.mol / u.m**3, "molar concentration"),
    (u.kg * u.m / u.s, "momentum/impulse"),
    (u.kg * u.m**2 / u.s, "angular momentum"),
    (u.rad / u.s, "angular speed"),
    (u.rad / u.s**2, "angular acceleration"),
    (u.g / (u.m * u.s), "dynamic viscosity"),
    (u.m**2 / u.s, "kinematic viscosity"),
    (u.m**-1, "wavenumber"),
    (u.A, "electrical current"),
    (u.C, "electrical charge"),
    (u.V, "electrical potential"),
    (u.Ohm, "electrical resistance"),
    (u.S, "electrical conductance"),
    (u.F, "electrical capacitance"),
    (u.C * u.m, "electrical dipole moment"),
    (u.A / u.m**2, "electrical current density"),
    (u.V / u.m, "electrical field strength"),
    (u.C / u.m**2, "electrical flux density"),
    (u.C / u.m**3, "electrical charge density"),
    (u.F / u.m, "permittivity"),
    (u.Wb, "magnetic flux"),
    (u.T, "magnetic flux density"),
    (u.A / u.m, "magnetic field strength"),
    (u.H / u.m, "electromagnetic field strength"),
    (u.H, "inductance"),
    (u.cd, "luminous intensity"),
    (u.lm, "luminous flux"),
    (u.lx, "luminous emittance/illuminance"),
    (u.W / u.sr, "radiant intensity"),
    (u.cd / u.m**2, "luminance"),
    (u.astrophys.Jy, "spectral flux density"),
    (u.astrophys.R, "photon flux"),
    (u.misc.bit, "data quantity"),
    (u.misc.bit / u.s, "bandwidth"),
    (u.cgs.Franklin, "electrical charge (ESU)"),
    (u.cgs.statampere, "electrical current (ESU)"),
    (u.cgs.Biot, "electrical current (EMU)"),
    (u.cgs.abcoulomb, "electrical charge (EMU)"),
    (u.imperial.btu / (u.s * u.m * u.imperial.deg_F), "thermal conductivity"),
    (u.imperial.cal / u.deg_C, "heat capacity"),
    (u.imperial.cal / u.deg_C / u.g, "specific heat capacity"),
    (u.J * u.m**-2 * u.s**-1, "energy flux"),
    (u.W / u.m**2, "energy flux"),
    (u.m**3 / u.mol, "molar volume"),
    (u.m / u.S, "electrical resistivity"),
    (u.S / u.m, "electrical conductivity"),
    (u.A * u.m**2, "magnetic moment"),
    (u.J / u.T, "magnetic moment"),
    (u.yr**-1 * u.Mpc**-3, "volumetric rate"),
    (u.m / u.s**3, "jerk"),
    (u.m / u.s**4, "snap"),
    (u.m / u.s**5, "crackle"),
    (u.m / u.s**6, "pop"),
    (u.deg_C / u.m, "temperature gradient"),
    (u.imperial.deg_F / u.m, "temperature gradient"),
    (u.imperial.deg_R / u.imperial.ft, "temperature gradient"),
    (u.imperial.Calorie / u.g, "specific energy"),
    (u.mol / u.L / u.s, "reaction rate"),
    (u.imperial.lbf * u.imperial.ft * u.s**2, "moment of inertia"),
    (u.mol / u.s, "catalytic activity"),
    (u.imperial.kcal / u.deg_C / u.mol, "molar heat capacity"),
    (u.mol / u.kg, "molality"),
    (u.imperial.inch * u.hr, "absement"),
    (u.imperial.ft**3 / u.s, "volumetric flow rate"),
    (u.Hz / u.s, "frequency drift"),
    (u.Pa**-1, "compressibility"),
    (u.dimensionless_unscaled, "dimensionless"),
]


@pytest.mark.parametrize("unit, physical_type", unit_physical_type_pairs)
def test_physical_type_names(unit, physical_type):
    """
    Test that the `physical_type` attribute of `u.Unit` objects provides
    the expected physical type for various units.

    Many of these tests are used to test backwards compatibility.
    """
    assert unit.physical_type == physical_type, (
        f"{unit!r}.physical_type was expected to return "
        f"{physical_type!r}, but instead returned {unit.physical_type!r}."
    )


length = u.m.physical_type
time = u.s.physical_type
speed = (u.m / u.s).physical_type
area = (u.m**2).physical_type
wavenumber = (u.m**-1).physical_type
dimensionless = u.dimensionless_unscaled.physical_type
pressure = u.Pa.physical_type
momentum = (u.kg * u.m / u.s).physical_type


@pytest.mark.parametrize(
    "physical_type_representation, physical_type_name",
    [
        (1.0, "dimensionless"),
        (u.m, "length"),
        ("work", "work"),
        (5 * u.m, "length"),
        (length, length),
        (u.Pa, "energy_density"),  # attribute-accessible name
        ("energy_density", "energy_density"),  # attribute-accessible name
    ],
)
def test_getting_physical_type(physical_type_representation, physical_type_name):
    """Test different ways of getting a physical type."""
    physical_type = physical.get_physical_type(physical_type_representation)
    assert isinstance(physical_type, physical.PhysicalType)
    assert physical_type == physical_type_name


@pytest.mark.parametrize(
    "argument, exception",
    [
        ("unknown", ValueError),
        ("not a name of a physical type", ValueError),
        ({"this set cannot be made into a Quantity"}, TypeError),
    ],
)
def test_getting_physical_type_exceptions(argument, exception):
    """
    Test that `get_physical_type` raises appropriate exceptions when
    provided with invalid arguments.
    """
    with pytest.raises(exception):
        physical.get_physical_type(argument)


def test_physical_type_cannot_become_quantity():
    """
    Test that `PhysicalType` instances cannot be cast into `Quantity`
    objects.  A failure in this test could be related to failures
    in subsequent tests.
    """
    with pytest.raises(TypeError):
        u.Quantity(u.m.physical_type, u.m)


# left term, right term, operator, expected value
operation_parameters = [
    (length, length, "__eq__", True),
    (length, area, "__eq__", False),
    (length, "length", "__eq__", True),
    ("length", length, "__eq__", NotImplemented),
    (dimensionless, dimensionless, "__eq__", True),
    (momentum, "momentum/impulse", "__eq__", True),  # test delimiters in names
    (pressure, "energy_density", "__eq__", True),  # test underscores in names
    ((u.m**8).physical_type, "unknown", "__eq__", True),
    ((u.m**8).physical_type, (u.m**9).physical_type, "__eq__", False),
    (length, length, "__ne__", False),
    (speed, time, "__ne__", True),
    (pressure, dimensionless, "__ne__", True),
    (length, u.m, "__eq__", NotImplemented),
    (length, length, "__mul__", area),
    (speed, time, "__mul__", length),
    (speed, time, "__rmul__", length),
    (length, time, "__truediv__", speed),
    (area, length, "__truediv__", length),
    (length, area, "__rtruediv__", length),
    (dimensionless, dimensionless, "__mul__", dimensionless),
    (dimensionless, dimensionless, "__truediv__", dimensionless),
    (length, 2, "__pow__", area),
    (area, 0.5, "__pow__", length),
    (dimensionless, 4, "__pow__", dimensionless),
    (u.m, length, "__mul__", NotImplemented),
    (3.2, length, "__mul__", NotImplemented),
    (u.m, time, "__truediv__", NotImplemented),
    (3.2, length, "__truediv__", NotImplemented),
    (length, u.m, "__mul__", area),
    (length, u.m, "__rmul__", area),
    (speed, u.s, "__mul__", length),
    (length, 1, "__mul__", length),
    (length, 1, "__rmul__", length),
    (length, u.s, "__truediv__", speed),
    (area, 1, "__truediv__", area),
    (time, u.m, "__rtruediv__", speed),
    (length, 1.0, "__rtruediv__", wavenumber),
    (length, 2, "__pow__", area),
    (length, 32, "__mul__", NotImplemented),
    (length, 0, "__rmul__", NotImplemented),
    (length, 3.2, "__truediv__", NotImplemented),
    (length, -1, "__rtruediv__", NotImplemented),
    (length, "length", "__mul__", area),
    (length, "length", "__rmul__", area),
    (area, "length", "__truediv__", length),
    (length, "area", "__rtruediv__", length),
]


@pytest.mark.parametrize("left, right, operator, expected", operation_parameters)
def test_physical_type_operations(left, right, operator, expected):
    """
    Test that `PhysicalType` dunder methods that require another
    argument behave as intended.
    """
    assert getattr(left, operator)(right) == expected


unit_with_physical_type_set = [
    (u.m, {"length"}),
    (u.kg * u.m / u.s, {"impulse", "momentum"}),
    (u.Pa, {"energy density", "pressure", "stress"}),
]


@pytest.mark.parametrize("unit, expected_set", unit_with_physical_type_set)
def test_physical_type_as_set(unit, expected_set):
    """Test making a `physical.PhysicalType` instance into a `set`."""
    resulting_set = set(unit.physical_type)
    assert resulting_set == expected_set


def test_physical_type_iteration():
    """Test iterating through different physical type names."""
    physical_type_names = list(pressure)
    assert physical_type_names == ["energy density", "pressure", "stress"]


def test_physical_type_in():
    """
    Test that `in` works as expected for `PhysicalType` objects with one
    or multiple names.
    """
    assert "length" in length
    assert "pressure" in pressure


equivalent_unit_pairs = [
    (u.m, u.m),
    (u.m, u.cm),
    (u.N, u.kg * u.m * u.s**-2),
    (u.barn * u.Mpc, u.cm**3),
    (u.K, u.deg_C),
    (u.K, u.imperial.deg_R),
    (u.K, u.imperial.deg_F),
    (u.deg_C, u.imperial.deg_F),
    (u.m**18, u.pc**18),
]


@pytest.mark.parametrize("unit1, unit2", equivalent_unit_pairs)
def test_physical_type_instance_equality(unit1, unit2):
    """
    Test that `physical.PhysicalType` instances for units of the same
    dimensionality are equal.
    """
    assert (unit1.physical_type == unit2.physical_type) is True
    assert (unit1.physical_type != unit2.physical_type) is False


@pytest.mark.parametrize("unit1, unit2", equivalent_unit_pairs)
def test_get_physical_type_equivalent_pairs(unit1, unit2):
    """
    Test that `get_physical_type` retrieves the same `PhysicalType`
    instances for equivalent physical types, except for unknown types
    which are not cataloged.
    """
    physical_type1 = physical.get_physical_type(unit1)
    physical_type2 = physical.get_physical_type(unit2)
    assert physical_type1 == physical_type2
    if physical_type1 != "unknown":
        assert physical_type1 is physical_type2


nonequivalent_unit_pairs = [
    (u.m, u.s),
    (u.m**18, u.m**19),
    (u.N, u.J),
    (u.barn, u.imperial.deg_F),
]


@pytest.mark.parametrize("unit1, unit2", nonequivalent_unit_pairs)
def test_physical_type_instance_inequality(unit1, unit2):
    """
    Test that `physical.PhysicalType` instances for units with different
    dimensionality are considered unequal.
    """
    physical_type1 = physical.PhysicalType(unit1, "ptype1")
    physical_type2 = physical.PhysicalType(unit2, "ptype2")
    assert (physical_type1 != physical_type2) is True
    assert (physical_type1 == physical_type2) is False


physical_type_with_expected_str = [
    (length, "length"),
    (speed, "speed/velocity"),
    (pressure, "energy density/pressure/stress"),
    (u.deg_C.physical_type, "temperature"),
    ((u.J / u.K / u.kg).physical_type, "specific entropy/specific heat capacity"),
]

physical_type_with_expected_repr = [
    (length, "PhysicalType('length')"),
    (speed, "PhysicalType({'speed', 'velocity'})"),
    (pressure, "PhysicalType({'energy density', 'pressure', 'stress'})"),
    (u.deg_C.physical_type, "PhysicalType('temperature')"),
    (
        (u.J / u.K / u.kg).physical_type,
        "PhysicalType({'specific entropy', 'specific heat capacity'})",
    ),
]


@pytest.mark.parametrize("physical_type, expected_str", physical_type_with_expected_str)
def test_physical_type_str(physical_type, expected_str):
    """Test using `str` on a `PhysicalType` instance."""
    assert str(physical_type) == expected_str


@pytest.mark.parametrize(
    "physical_type, expected_repr", physical_type_with_expected_repr
)
def physical_type_repr(physical_type, expected_repr):
    """Test using `repr` on a `PhysicalType` instance."""
    assert repr(physical_type) == expected_repr


def test_physical_type_hash():
    """Test that a `PhysicalType` instance can be used as a dict key."""
    dictionary = {length: 42}
    assert dictionary[length] == 42


@pytest.mark.parametrize("multiplicand", [list(), 42, 0, -1])
def test_physical_type_multiplication(multiplicand):
    """
    Test that multiplication of a physical type returns `NotImplemented`
    when attempted for an invalid type.
    """
    with pytest.raises(TypeError):
        length * multiplicand


def test_unrecognized_unit_physical_type():
    """
    Test basic functionality for the physical type of an unrecognized
    unit.
    """
    unrecognized_unit = u.Unit("parrot", parse_strict="silent")
    physical_type = unrecognized_unit.physical_type
    assert isinstance(physical_type, physical.PhysicalType)
    assert physical_type == "unknown"


invalid_inputs = [(42,), ("valid input", 42)]


@pytest.mark.parametrize("invalid_input", invalid_inputs)
def test_invalid_physical_types(invalid_input):
    """
    Test that `PhysicalType` cannot be instantiated when one of the
    supplied names is not a string, while making sure that the physical
    type for the unit remains unknown.
    """
    obscure_unit = u.s**87
    with pytest.raises(ValueError):
        physical.PhysicalType(obscure_unit, invalid_input)
    assert obscure_unit.physical_type == "unknown"


class TestDefPhysType:
    weird_unit = u.m**99
    strange_unit = u.s**42

    def test_attempt_to_define_unknown_physical_type(self):
        """Test that a unit cannot be defined as unknown."""
        with pytest.raises(ValueError):
            physical.def_physical_type(self.weird_unit, "unknown")
        assert "unknown" not in physical._unit_physical_mapping

    def test_multiple_same_physical_type_names(self):
        """
        Test that `def_physical_type` raises an exception when it tries to
        set the physical type of a new unit as the name of an existing
        physical type.
        """
        with pytest.raises(ValueError):
            physical.def_physical_type(self.weird_unit, {"time", "something"})
        assert self.weird_unit.physical_type == "unknown"

    def test_expanding_names_for_physical_type(self):
        """
        Test that calling `def_physical_type` on an existing physical
        type adds a new physical type name.
        """
        weird_name = "weird name"
        strange_name = "strange name"

        try:
            physical.def_physical_type(self.weird_unit, weird_name)
            assert (
                self.weird_unit.physical_type == weird_name
            ), f"unable to set physical type for {self.weird_unit}"
        except Exception:
            raise
        finally:  # cleanup added name
            physical._attrname_physical_mapping.pop(weird_name.replace(" ", "_"), None)
            physical._name_physical_mapping.pop(weird_name, None)

        # add both strange_name and weird_name
        try:
            physical.def_physical_type(self.weird_unit, strange_name)
            assert set((self.weird_unit).physical_type) == {
                weird_name,
                strange_name,
            }, "did not correctly append a new physical type name."
        except Exception:
            raise
        finally:  # cleanup added names
            physical._attrname_physical_mapping.pop(
                strange_name.replace(" ", "_"), None
            )
            physical._name_physical_mapping.pop(strange_name, None)
            physical._attrname_physical_mapping.pop(weird_name.replace(" ", "_"), None)
            physical._name_physical_mapping.pop(weird_name, None)

    def test_redundant_physical_type(self):
        """
        Test that a physical type name already in use cannot be assigned
        for another unit (excluding `"unknown"`).
        """
        with pytest.raises(ValueError):
            physical.def_physical_type(self.weird_unit, "length")

    @staticmethod
    def _undef_physical_type(unit):
        """Reset the physical type of unit to "unknown"."""
        for name in list(unit.physical_type):
            del physical._unit_physical_mapping[name]
        del physical._physical_unit_mapping[unit._get_physical_type_id()]
        assert unit.physical_type == "unknown"

    def teardown_method(self):
        """
        Remove the definitions of the physical types that were added
        using `def_physical_unit` for testing purposes.
        """
        for unit in [self.weird_unit, self.strange_unit]:
            physical_type = physical.get_physical_type(unit)
            if physical_type != "unknown":
                self._undef_physical_type(unit)
            assert unit.physical_type == "unknown", (
                f"the physical type for {unit}, which was added for"
                "testing, was not deleted."
            )


@pytest.mark.parametrize(
    "method, expected",
    [("title", "Length"), ("isalpha", True), ("isnumeric", False), ("upper", "LENGTH")],
)
def test_that_str_methods_work_with_physical_types(method, expected):
    """
    Test that str methods work for `PhysicalType` instances while issuing
    a deprecation warning.
    """
    with pytest.warns(AstropyDeprecationWarning, match="PhysicalType instances"):
        result_of_method_call = getattr(length, method)()
    assert result_of_method_call == expected


def test_missing_physical_type_attribute():
    """
    Test that a missing attribute raises an `AttributeError`.

    This test should be removed when the deprecated option of calling
    string methods on PhysicalType instances is removed from
    `PhysicalType.__getattr__`.
    """
    with pytest.raises(AttributeError):
        length.not_the_name_of_a_str_or_physical_type_attribute


@pytest.mark.parametrize("ptype_name", ["length", "speed", "entropy"])
def test_pickling(ptype_name):
    # Regression test for #11685
    ptype = u.get_physical_type(ptype_name)
    pkl = pickle.dumps(ptype)
    other = pickle.loads(pkl)
    assert other == ptype


def test_physical_types_module_access():
    # all physical type names in dir
    assert set(dir(physical)).issuperset(physical._attrname_physical_mapping.keys())
    assert set(dir(physical)).issuperset(physical.__all__)

    # all physical type can be accessed by name
    for pname in physical._attrname_physical_mapping.keys():
        ptype = physical._attrname_physical_mapping[pname]
        assert hasattr(physical, pname)  # make sure works in lazy load
        assert getattr(physical, pname) is ptype

    # a failed access
    with pytest.raises(AttributeError, match="has no attribute"):
        physical.not_a_valid_physical_type_name
