"""
Unit tests of the helper functions in core.utils
"""

import datetime
import math
import os
from itertools import product
from pathlib import Path
from zoneinfo import ZoneInfo

import narwhals.stable.v2 as nw
import numpy as np
import pandas as pd
import pytest

import holoviews as hv
from holoviews.core import util
from holoviews.core.util import (
    _minmax_finite,
    closest_match,
    compute_density,
    compute_edges,
    cross_index,
    date_range,
    deephash,
    dimension_range,
    dt_to_int,
    dtype_kind,
    find_range,
    get_path,
    is_nan,
    is_null_or_na_scalar,
    isdatetime,
    isfinite,
    make_path_unique,
    max_range,
    merge_dimensions,
    sanitize_identifier_fn,
    search_indices,
    tree_attribute,
    unique_array,
    wrap_tuple_streams,
)
from holoviews.core.util.types import masked_types
from holoviews.streams import PointerXY
from holoviews.testing import assert_data_equal

sanitize_identifier = sanitize_identifier_fn.instance()


@pytest.fixture
def with_pandas(request, monkeypatch):
    """Fixture to control pandas availability"""
    if request.param:
        pytest.importorskip("pandas")
    else:
        monkeypatch.setattr(util, "pd", None)


def with_and_without_pandas(func):
    """Decorator to test both with and without pandas"""
    return pytest.mark.parametrize(
        "with_pandas", [True, False], indirect=True, ids=["with_pandas", "without_pandas"]
    )(func)


class TestDeepHash:
    """
    Tests of deephash function used for memoization.
    """

    def test_deephash_list_equality(self):
        assert deephash([1, 2, 3]) == deephash([1, 2, 3])

    def test_deephash_list_inequality(self):
        obj1 = [1, 2, 3]
        obj2 = [1, 2, 3, 4]
        assert deephash(obj1) != deephash(obj2)

    def test_deephash_set_equality(self):
        assert deephash({1, 2, 3}) == deephash({1, 3, 2})

    def test_deephash_set_inequality(self):
        assert deephash({1, 2, 3}) != deephash({1, 3, 4})

    def test_deephash_dict_equality_v1(self):
        assert deephash({1: "a", 2: "b"}) == deephash({2: "b", 1: "a"})

    def test_deephash_dict_equality_v2(self):
        assert deephash({1: "a", 2: "b"}) != deephash({2: "b", 1: "c"})

    def test_deephash_odict_equality_v1(self):
        odict1 = dict([(1, "a"), (2, "b")])
        odict2 = dict([(1, "a"), (2, "b")])
        assert deephash(odict1) == deephash(odict2)

    def test_deephash_odict_equality_v2(self):
        odict1 = dict([(1, "a"), (2, "b")])
        odict2 = dict([(1, "a"), (2, "c")])
        assert deephash(odict1) != deephash(odict2)

    def test_deephash_numpy_equality(self):
        assert deephash(np.array([1, 2, 3])) == deephash(np.array([1, 2, 3]))

    def test_deephash_numpy_inequality(self):
        arr1 = np.array([1, 2, 3])
        arr2 = np.array([1, 2, 4])
        assert deephash(arr1) != deephash(arr2)

    def test_deephash_dataframe_equality(self):
        obj1 = deephash(pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]}))
        obj2 = deephash(pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]}))
        assert obj1 == obj2

    def test_deephash_dataframe_column_inequality(self):
        obj1 = deephash(pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]}))
        obj2 = deephash(pd.DataFrame({"a": [1, 2, 3], "c": [4, 5, 6]}))
        assert obj1 != obj2

    def test_deephash_dataframe_index_inequality(self):
        obj1 = deephash(pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]}))
        obj2 = deephash(
            pd.DataFrame(
                {"a": [1, 2, 3], "b": [4, 5, 6]}, index=pd.Series([0, 1, 2], name="Index")
            )
        )
        assert obj1 != obj2

    def test_deephash_dataframe_inequality(self):
        obj1 = deephash(pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]}))
        obj2 = deephash(pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 8]}))
        assert obj1 != obj2

    def test_deephash_series_equality(self):
        assert deephash(pd.Series([1, 2, 3])) == deephash(pd.Series([1, 2, 3]))

    def test_deephash_series_name_inequality(self):
        obj1 = deephash(pd.Series([1, 2, 3], name="Foo"))
        obj2 = deephash(pd.Series([1, 2, 3], name="Bar"))
        assert obj1 != obj2

    def test_deephash_series_index_inequality(self):
        obj1 = deephash(pd.Series([1, 2, 3], index=pd.Series([0, 1, 2], name="Index")))
        obj2 = deephash(pd.Series([1, 2, 3], index=pd.Series([2, 1, 0], name="Index")))
        assert obj1 != obj2

    def test_deephash_series_index_name_inequality(self):
        obj1 = deephash(pd.Series([1, 2, 3], index=pd.Series([0, 1, 2], name="Foo")))
        obj2 = deephash(pd.Series([1, 2, 3], index=pd.Series([0, 1, 2], name="Bar")))
        assert obj1 != obj2

    def test_deephash_series_inequality(self):
        assert deephash(pd.Series([1, 2, 3])) != deephash(pd.Series([1, 2, 7]))

    def test_deephash_datetime_equality(self):
        dt1 = datetime.datetime(1, 2, 3)
        dt2 = datetime.datetime(1, 2, 3)
        assert deephash(dt1) == deephash(dt2)

    def test_deephash_datetime_inequality(self):
        dt1 = datetime.datetime(1, 2, 3)
        dt2 = datetime.datetime(1, 2, 5)
        assert deephash(dt1) != deephash(dt2)

    def test_deephash_nested_native_equality(self):
        obj1 = [[1, 2], (3, 6, 7, [True]), "a", 9.2, 42, {1: 3, 2: "c"}]
        obj2 = [[1, 2], (3, 6, 7, [True]), "a", 9.2, 42, {1: 3, 2: "c"}]
        assert deephash(obj1) == deephash(obj2)

    def test_deephash_nested_native_inequality(self):
        obj1 = [[1, 2], (3, 6, 7, [False]), "a", 9.2, 42, {1: 3, 2: "c"}]
        obj2 = [[1, 2], (3, 6, 7, [True]), "a", 9.2, 42, {1: 3, 2: "c"}]
        assert deephash(obj1) != deephash(obj2)

    def test_deephash_nested_mixed_equality(self):
        obj1 = [
            datetime.datetime(1, 2, 3),
            {1, 2, 3},
            pd.DataFrame({"a": [1, 2], "b": [3, 4]}),
            np.array([1, 2, 3]),
            {"a": "b", "1": True},
            dict([(1, "a"), (2, "b")]),
            np.int64(34),
        ]
        obj2 = [
            datetime.datetime(1, 2, 3),
            {1, 2, 3},
            pd.DataFrame({"a": [1, 2], "b": [3, 4]}),
            np.array([1, 2, 3]),
            {"a": "b", "1": True},
            dict([(1, "a"), (2, "b")]),
            np.int64(34),
        ]
        assert deephash(obj1) == deephash(obj2)

    def test_deephash_nested_mixed_inequality(self):
        obj1 = [
            datetime.datetime(1, 2, 3),
            {1, 2, 3},
            pd.DataFrame({"a": [1, 2], "b": [3, 4]}),
            np.array([1, 2, 3]),
            {"a": "b", "2": True},
            dict([(1, "a"), (2, "b")]),
            np.int64(34),
        ]
        obj2 = [
            datetime.datetime(1, 2, 3),
            {1, 2, 3},
            pd.DataFrame({"a": [1, 2], "b": [3, 4]}),
            np.array([1, 2, 3]),
            {"a": "b", "1": True},
            dict([(1, "a"), (2, "b")]),
            np.int64(34),
        ]
        assert deephash(obj1) != deephash(obj2)


class TestAllowablePrefix:
    """
    Tests of allowable and hasprefix method.
    """

    def test_allowable_false_1(self):
        assert not sanitize_identifier.allowable("trait_names")

    def test_allowable_false_2(self):
        assert not sanitize_identifier.allowable("_repr_png_")

    def test_allowable_false_3(self):
        assert not sanitize_identifier.allowable("_ipython_display_")

    def test_allowable_false_underscore(self):
        assert not sanitize_identifier.allowable("_foo", True)

    def test_allowable_true(self):
        assert sanitize_identifier.allowable("some_string")

    def test_prefix_test1(self):
        prefixed = sanitize_identifier.prefixed("_some_string")
        assert prefixed

    def test_prefix_test2(self):
        prefixed = sanitize_identifier.prefixed("some_string")
        assert not prefixed

    def test_prefix_test3(self):
        prefixed = sanitize_identifier.prefixed("۵some_string")
        assert prefixed


class TestTreeAttribute:
    def test_simple_lowercase_string(self):
        assert not tree_attribute("lowercase")

    def test_simple_uppercase_string(self):
        assert tree_attribute("UPPERCASE")

    def test_underscore_string(self):
        assert not tree_attribute("_underscore")


class TestSanitization:
    """
    Tests of sanitize_identifier
    """

    def test_simple_pound_sanitized(self):
        sanitized = sanitize_identifier("£")
        assert sanitized == "pound"

    def test_simple_digit_sanitized(self):
        sanitized = sanitize_identifier("0")
        assert sanitized == "A_0"

    def test_simple_underscore_sanitized(self):
        sanitized = sanitize_identifier("_test")
        assert sanitized == "A__test"

    def test_simple_alpha_sanitized(self):
        sanitized = sanitize_identifier("α")
        assert sanitized == "α"

    def test_simple_a_pound_sanitized(self):
        sanitized = sanitize_identifier("a £")
        assert sanitized == "A_pound"

    def test_capital_delta_sanitized(self):
        sanitized = sanitize_identifier("Δ")
        assert sanitized == "Δ"

    def test_lowercase_delta_sanitized(self):
        sanitized = sanitize_identifier("δ")
        assert sanitized == "δ"

    def test_simple_alpha_beta_sanitized(self):
        sanitized = sanitize_identifier("α β")
        assert sanitized == "α_β"

    def test_simple_alpha_beta_underscore_sanitized(self):
        sanitized = sanitize_identifier("α_β")
        assert sanitized == "α_β"

    def test_simple_alpha_beta_double_underscore_sanitized(self):
        sanitized = sanitize_identifier("α__β")
        assert sanitized == "α__β"

    def test_simple_alpha_beta_mixed_underscore_space_sanitized(self):
        sanitized = sanitize_identifier("α__  β")
        assert sanitized == "α__β"

    def test_alpha_times_two(self):
        sanitized = sanitize_identifier("α*2")
        assert sanitized == "α_times_2"

    def test_urdu_a_five_sanitized(self):
        """
        Note: There would be a clash if you mixed the languages of
        your digits! E.g. arabic ٥ five and urdu ۵ five
        """
        sanitized = sanitize_identifier("a ۵")
        assert sanitized == "A_۵"

    def test_umlaut_sanitized(self):
        sanitized = sanitize_identifier("Festkörperphysik")
        assert sanitized == "Festkörperphysik"

    def test_power_umlaut_sanitized(self):
        sanitized = sanitize_identifier("^Festkörperphysik")
        assert sanitized == "power_Festkörperphysik"

    def test_custom_dollar_removal_py2(self):
        sanitize_identifier.eliminations.extend(["dollar"])
        sanitized = sanitize_identifier("$E$")
        assert sanitized == "E"
        sanitize_identifier.eliminations.remove("dollar")


class TestFindRange:
    """
    Tests for find_range function.
    """

    def setup_method(self):
        self.int_vals = [1, 5, 3, 9, 7, 121, 14]
        self.float_vals = [0.38, 0.121, -0.1424, 5.12]
        self.nan_floats = [np.nan, 0.32, 1.42, -0.32]
        self.str_vals = ["Aardvark", "Zebra", "Platypus", "Wallaby"]

    def test_int_range(self):
        assert find_range(self.int_vals) == (1, 121)

    def test_float_range(self):
        assert find_range(self.float_vals) == (-0.1424, 5.12)

    def test_nan_range(self):
        assert find_range(self.nan_floats) == (-0.32, 1.42)

    def test_str_range(self):
        assert find_range(self.str_vals) == ("Aardvark", "Zebra")

    def test_soft_range(self):
        assert find_range(self.float_vals, soft_range=(np.nan, 100)) == (-0.1424, 100)


class TestDimensionRange:
    """
    Tests for dimension_range function.
    """

    def setup_method(self):
        self.date_range = (
            np.datetime64(datetime.datetime(2017, 1, 1)),
            np.datetime64(datetime.datetime(2017, 1, 2)),
        )
        self.date_range2 = (
            np.datetime64(datetime.datetime(2016, 12, 31)),
            np.datetime64(datetime.datetime(2017, 1, 3)),
        )

    def test_dimension_range_date_hard_range(self):
        drange = dimension_range(
            self.date_range2[0], self.date_range2[1], self.date_range, (None, None)
        )
        assert drange == self.date_range

    def test_dimension_range_date_soft_range(self):
        drange = dimension_range(
            self.date_range[0], self.date_range[1], (None, None), self.date_range2
        )
        assert drange == self.date_range2

    def test_both_hard_finite_short_circuits(self):
        result = dimension_range(0.0, 100.0, (10.0, 90.0), (None, None))
        assert result == (10.0, 90.0)

    def test_both_hard_finite_np_scalars(self):
        """hard_range from max_range output contains numpy scalars."""
        result = dimension_range(
            np.float64(0.0),
            np.float64(100.0),
            (np.float64(10.0), np.float64(90.0)),
            (None, None),
        )
        assert result == (np.float64(10.0), np.float64(90.0))

    def test_both_hard_finite_ignores_soft_range(self):
        result = dimension_range(0.0, 100.0, (10.0, 90.0), (0.0, 200.0))
        assert result == (10.0, 90.0)

    def test_both_hard_finite_ignores_padding(self):
        result = dimension_range(0.0, 100.0, (10.0, 90.0), (None, None), padding=(0.1, 0.1))
        assert result == (10.0, 90.0)

    def test_one_hard_bound_does_not_short_circuit(self):
        result = dimension_range(0.0, 100.0, (10.0, None), (None, None))
        assert result[0] == 10.0
        assert result[1] == 100.0

    def test_nan_hard_range_does_not_short_circuit(self):
        result = dimension_range(0.0, 100.0, (np.nan, np.nan), (None, None))
        assert result == (0.0, 100.0)

    def test_soft_range_extends_data(self):
        result = dimension_range(5.0, 95.0, (None, None), (0.0, 100.0))
        assert result == (0.0, 100.0)

    def test_soft_range_within_data(self):
        result = dimension_range(0.0, 100.0, (None, None), (20.0, 80.0))
        assert result == (0.0, 100.0)


class TestMinmaxFinite:
    """Tests for _minmax_finite helper."""

    def test_basic(self):
        assert _minmax_finite([1.0, 3.0, 2.0]) == (1.0, 3.0)

    def test_skips_none(self):
        assert _minmax_finite([None, 2.0, None, 1.0]) == (1.0, 2.0)

    def test_skips_nan(self):
        assert _minmax_finite([float("nan"), 2.0, float("nan"), 1.0]) == (1.0, 2.0)

    def test_all_none(self):
        lo, hi = _minmax_finite([None, None])
        assert math.isnan(lo)
        assert math.isnan(hi)

    def test_all_nan(self):
        lo, hi = _minmax_finite([float("nan"), float("nan")])
        assert math.isnan(lo)
        assert math.isnan(hi)

    def test_empty(self):
        lo, hi = _minmax_finite([])
        assert math.isnan(lo)
        assert math.isnan(hi)

    def test_preserves_numpy_scalar_type(self):
        lo, hi = _minmax_finite([np.float64(3.0), np.int64(1)])
        assert type(lo) is np.int64
        assert type(hi) is np.float64

    def test_preserves_python_types(self):
        lo, hi = _minmax_finite([3.0, 1, 2.5])
        assert type(lo) is int
        assert type(hi) is float

    def test_single_value(self):
        lo, hi = _minmax_finite([42.0])
        assert lo == hi == 42.0

    def test_inf(self):
        assert _minmax_finite([float("inf"), 1.0]) == (1.0, float("inf"))

    def test_neg_inf(self):
        assert _minmax_finite([float("-inf"), 1.0]) == (float("-inf"), 1.0)


class TestMaxRange:
    """
    Tests for max_range function.
    """

    def setup_method(self):
        self.ranges1 = [(-0.2, 0.5), (0, 1), (-0.37, 1.02), (np.nan, 0.3)]
        self.ranges2 = [(np.nan, np.nan), (np.nan, np.nan)]

    def test_max_range1(self):
        assert max_range(self.ranges1) == (-0.37, 1.02)

    def test_max_range2(self):
        lower, upper = max_range(self.ranges2)
        assert math.isnan(lower)
        assert math.isnan(upper)

    def test_max_range3(self):
        periods = [(pd.Period("1990", freq="M"), pd.Period("1991", freq="M"))]
        expected = (np.datetime64("1990", "ns"), np.datetime64("1991", "ns"))
        assert max_range(periods) == expected

    # ── Numeric fast path ─────────────────────────────────────────

    def test_empty(self):
        lo, hi = max_range([])
        assert math.isnan(lo)
        assert math.isnan(hi)

    @pytest.mark.parametrize("combined", [True, False])
    def test_single_float_tuple(self, combined):
        assert max_range([(1.0, 5.0)], combined=combined) == (1.0, 5.0)

    @pytest.mark.parametrize("combined", [True, False])
    def test_multiple_float_tuples(self, combined):
        assert max_range([(1.0, 5.0), (2.0, 3.0)], combined=combined) == (1.0, 5.0)

    @pytest.mark.parametrize("combined", [True, False])
    def test_all_none(self, combined):
        lo, hi = max_range([(None, None)], combined=combined)
        assert math.isnan(lo)
        assert math.isnan(hi)

    @pytest.mark.parametrize("combined", [True, False])
    def test_none_mixed_with_float(self, combined):
        assert max_range([(None, 5.0), (2.0, None)], combined=combined) == (2.0, 5.0)

    def test_nan_mixed_with_float(self):
        assert max_range([(np.nan, np.nan), (1.0, 2.0)]) == (1.0, 2.0)

    def test_all_nan(self):
        lo, hi = max_range([(np.nan, np.nan)])
        assert math.isnan(lo)
        assert math.isnan(hi)

    def test_inf(self):
        assert max_range([(float("inf"), float("inf"))]) == (float("inf"), float("inf"))

    def test_neg_inf(self):
        assert max_range([(float("-inf"), float("inf"))], combined=False) == (
            float("-inf"),
            float("inf"),
        )

    def test_np_int64(self):
        assert max_range([(np.int64(1), np.int64(5))]) == (1.0, 5.0)

    def test_np_int64_uncombined(self):
        result = max_range(
            [(np.int64(1), np.int64(10)), (np.int64(3), np.int64(7))], combined=False
        )
        assert result == (1.0, 10.0)

    def test_np_float64(self):
        assert max_range([(np.float64(1.0), np.float64(5.0))]) == (1.0, 5.0)

    @pytest.mark.parametrize("combined", [True, False])
    def test_np_scalars_mixed_with_none(self, combined):
        """Realistic case: find_range returns numpy scalars, Dimension defaults are None."""
        result = max_range([(np.float64(0.3), np.float64(9.7)), (None, None)], combined=combined)
        assert result == (0.3, 9.7)

    def test_mixed_np_scalar_and_python_float(self):
        assert max_range([(np.float64(1.0), 5.0), (2.0, np.float64(10.0))]) == (1.0, 10.0)

    @pytest.mark.parametrize("combined", [True, False])
    def test_negative_floats(self, combined):
        assert max_range([(-10.0, -1.0), (-5.0, -2.0)], combined=combined) == (-10.0, -1.0)

    def test_identical_ranges(self):
        assert max_range([(5.0, 5.0), (5.0, 5.0)]) == (5.0, 5.0)

    def test_large_values(self):
        assert max_range([(1e15, 1e16), (1e14, 1e17)]) == (1e14, 1e17)

    def test_mixed_int_float_np_types(self):
        result = max_range([(1, np.float64(10.0)), (np.int64(2), 8.0)])
        assert result == (1, np.float64(10.0))

    # ── Fast-path type preservation ───────────────────────────────

    def test_fast_path_preserves_python_int(self):
        lo, hi = max_range([(1, 5)])
        assert type(lo) is int
        assert type(hi) is int

    def test_fast_path_preserves_python_float(self):
        lo, hi = max_range([(1.0, 5.0)])
        assert type(lo) is float
        assert type(hi) is float

    def test_fast_path_preserves_np_float64(self):
        lo, hi = max_range([(np.float64(1.0), np.float64(5.0))])
        assert type(lo) is np.float64
        assert type(hi) is np.float64

    def test_fast_path_preserves_np_int64(self):
        lo, hi = max_range([(np.int64(1), np.int64(5))])
        assert type(lo) is np.int64
        assert type(hi) is np.int64

    # ── Fallback path (non-numeric types) ─────────────────────────

    def test_datetime64(self):
        result = max_range([(np.datetime64("2021-01-01"), np.datetime64("2021-12-31"))])
        assert result[0] <= result[1]

    def test_string_ranges(self):
        assert max_range([("a", "z"), ("b", "y")]) == ("a", "z")


class TestWrapTupleStreams:
    def test_no_streams(self):
        result = wrap_tuple_streams((1, 2), [], [])
        assert result == (1, 2)

    def test_no_streams_two_kdims(self):
        result = wrap_tuple_streams((1, 2), [hv.Dimension("x"), hv.Dimension("y")], [])
        assert result == (1, 2)

    def test_no_streams_none_value(self):
        result = wrap_tuple_streams((1, None), [hv.Dimension("x"), hv.Dimension("y")], [])
        assert result == (1, None)

    def test_no_streams_one_stream_substitution(self):
        result = wrap_tuple_streams(
            (None, 3), [hv.Dimension("x"), hv.Dimension("y")], [PointerXY(x=-5, y=10)]
        )
        assert result == (-5, 3)

    def test_no_streams_two_stream_substitution(self):
        result = wrap_tuple_streams(
            (None, None), [hv.Dimension("x"), hv.Dimension("y")], [PointerXY(x=0, y=5)]
        )
        assert result == (0, 5)


class TestMergeDimensions:
    def test_merge_dimensions(self):
        dimensions = merge_dimensions(
            [[hv.Dimension("A")], [hv.Dimension("A"), hv.Dimension("B")]]
        )
        assert dimensions == [hv.Dimension("A"), hv.Dimension("B")]

    def test_merge_dimensions_with_values(self):
        dimensions = merge_dimensions(
            [
                [hv.Dimension("A", values=[0, 1])],
                [hv.Dimension("A", values=[1, 2]), hv.Dimension("B")],
            ]
        )
        assert dimensions == [hv.Dimension("A"), hv.Dimension("B")]
        assert dimensions[0].values == [0, 1, 2]


class TestTreePathUtils:
    def test_get_path_with_label(self):
        path = get_path(hv.Element("Test", label="A"))
        assert path == ("Element", "A")

    def test_get_path_without_label(self):
        path = get_path(hv.Element("Test"))
        assert path == ("Element",)

    def test_get_path_with_custom_group(self):
        path = get_path(hv.Element("Test", group="Custom Group"))
        assert path == ("Custom_Group",)

    def test_get_path_with_custom_group_and_label(self):
        path = get_path(hv.Element("Test", group="Custom Group", label="A"))
        assert path == ("Custom_Group", "A")

    def test_get_path_from_item_with_custom_group(self):
        path = get_path((("Custom",), hv.Element("Test")))
        assert path == ("Custom",)

    def test_get_path_from_item_with_custom_group_and_label(self):
        path = get_path((("Custom", "Path"), hv.Element("Test")))
        assert path == ("Custom",)

    def test_get_path_from_item_with_custom_group_and_matching_label(self):
        path = get_path((("Custom", "Path"), hv.Element("Test", label="Path")))
        assert path == ("Custom", "Path")

    def test_make_path_unique_no_clash(self):
        path = ("Element", "A")
        new_path = make_path_unique(path, {}, True)
        assert new_path == path

    def test_make_path_unique_clash_without_label(self):
        path = ("Element",)
        new_path = make_path_unique(path, {path: 1}, True)
        assert new_path == (*path, "I")

    def test_make_path_unique_clash_with_label(self):
        path = ("Element", "A")
        new_path = make_path_unique(path, {path: 1}, True)
        assert new_path == (*path, "I")

    def test_make_path_unique_no_clash_old(self):
        path = ("Element", "A")
        new_path = make_path_unique(path, {}, False)
        assert new_path == path

    def test_make_path_unique_clash_without_label_old(self):
        path = ("Element",)
        new_path = make_path_unique(path, {path: 1}, False)
        assert new_path == (*path, "I")

    def test_make_path_unique_clash_with_label_old(self):
        path = ("Element", "A")
        new_path = make_path_unique(path, {path: 1}, False)
        assert new_path == (*path[:-1], "I")


class TestDatetimeUtils:
    def test_compute_density_float(self):
        assert compute_density(0, 1, 10) == 10

    def test_compute_us_density_1s_datetime(self):
        start = np.datetime64(datetime.datetime.today())
        end = start + np.timedelta64(1, "s")
        assert compute_density(start, end, 10) == 1e-5

    def test_compute_us_density_10s_datetime(self):
        start = np.datetime64(datetime.datetime.today())
        end = start + np.timedelta64(10, "s")
        assert compute_density(start, end, 10) == 1e-6

    def test_compute_s_density_1s_datetime(self):
        start = np.datetime64(datetime.datetime.today())
        end = start + np.timedelta64(1, "s")
        assert compute_density(start, end, 10, "s") == 10

    def test_compute_s_density_10s_datetime(self):
        start = np.datetime64(datetime.datetime.today())
        end = start + np.timedelta64(10, "s")
        assert compute_density(start, end, 10, "s") == 1

    def test_datetime_to_us_int(self):
        dt = datetime.datetime(2017, 1, 1)
        assert dt_to_int(dt) == 1483228800000000.0

    def test_datetime64_s_to_ns_int(self):
        dt = np.datetime64(datetime.datetime(2017, 1, 1), "s")
        assert dt_to_int(dt, "ns") == 1483228800000000000.0

    def test_datetime64_us_to_ns_int(self):
        dt = np.datetime64(datetime.datetime(2017, 1, 1), "us")
        assert dt_to_int(dt, "ns") == 1483228800000000000.0

    def test_datetime64_to_ns_int(self):
        dt = np.datetime64(datetime.datetime(2017, 1, 1))
        assert dt_to_int(dt, "ns") == 1483228800000000000.0

    def test_datetime64_us_to_us_int(self):
        dt = np.datetime64(datetime.datetime(2017, 1, 1), "us")
        assert dt_to_int(dt) == 1483228800000000.0

    def test_datetime64_s_to_us_int(self):
        dt = np.datetime64(datetime.datetime(2017, 1, 1), "s")
        assert dt_to_int(dt) == 1483228800000000.0

    def test_timestamp_to_us_int(self):
        dt = pd.Timestamp(datetime.datetime(2017, 1, 1))
        assert dt_to_int(dt) == 1483228800000000.0

    def test_datetime_to_s_int(self):
        dt = datetime.datetime(2017, 1, 1)
        assert dt_to_int(dt, "s") == 1483228800.0

    def test_datetime64_to_s_int(self):
        dt = np.datetime64(datetime.datetime(2017, 1, 1))
        assert dt_to_int(dt, "s") == 1483228800.0

    def test_datetime64_us_to_s_int(self):
        dt = np.datetime64(datetime.datetime(2017, 1, 1), "us")
        assert dt_to_int(dt, "s") == 1483228800.0

    def test_datetime64_s_to_s_int(self):
        dt = np.datetime64(datetime.datetime(2017, 1, 1), "s")
        assert dt_to_int(dt, "s") == 1483228800.0

    def test_timestamp_to_s_int(self):
        dt = pd.Timestamp(datetime.datetime(2017, 1, 1))
        assert dt_to_int(dt, "s") == 1483228800.0

    def test_date_range_1_hour(self):
        start = np.datetime64(datetime.datetime(2017, 1, 1))
        end = start + np.timedelta64(1, "h")
        drange = date_range(start, end, 6)
        assert drange[0] == start + np.timedelta64(5, "m")
        assert drange[-1] == end - np.timedelta64(5, "m")

    def test_date_range_1_sec(self):
        start = np.datetime64(datetime.datetime(2017, 1, 1))
        end = start + np.timedelta64(1, "s")
        drange = date_range(start, end, 10)
        assert drange[0] == start + np.timedelta64(50, "ms")
        assert drange[-1] == end - np.timedelta64(50, "ms")

    def test_timezone_to_int(self):
        timezone = ZoneInfo("Europe/Copenhagen")

        values = [
            datetime.datetime(2021, 4, 8, 12, 0, 0, 0),
            datetime.datetime(2021, 4, 8, 12, 0, 0, 0, datetime.timezone.utc),
            datetime.datetime(2021, 4, 8, 12, 0, 0, 0, timezone),
            datetime.date(2021, 4, 8),
            np.datetime64(datetime.datetime(2021, 4, 8, 12, 0, 0, 0)),
        ]

        for value in values:
            x1 = dt_to_int(value)
            x2 = dt_to_int(pd.to_datetime(value))
            assert x1 == x2


class TestNumericUtilities:
    def test_isfinite_none(self):
        assert not isfinite(None)

    def test_isfinite_nan(self):
        assert not isfinite(float("NaN"))

    def test_isfinite_inf(self):
        assert not isfinite(float("inf"))

    def test_isfinite_float(self):
        assert isfinite(1.2)

    def test_isfinite_float_array_nan(self):
        array = np.array([1.2, 3.0, np.nan])
        assert_data_equal(isfinite(array), np.array([True, True, False]))

    def test_isfinite_float_array_inf(self):
        array = np.array([1.2, 3.0, np.inf])
        assert_data_equal(isfinite(array), np.array([True, True, False]))

    def test_isfinite_datetime(self):
        dt = datetime.datetime(2017, 1, 1)
        assert isfinite(dt)

    def test_isfinite_datetime64(self):
        dt64 = np.datetime64(datetime.datetime(2017, 1, 1))
        assert isfinite(dt64)

    def test_isfinite_datetime64_nat(self):
        dt64 = np.datetime64("NaT")
        assert not isfinite(dt64)

    def test_isfinite_timedelta64_nat(self):
        dt64 = np.timedelta64("NaT")
        assert not isfinite(dt64)

    def test_isfinite_pandas_timestamp_nat(self):
        dt64 = pd.Timestamp("NaT")
        assert not isfinite(dt64)

    def test_isfinite_pandas_period_nat(self):
        dt64 = pd.Period("NaT")
        assert not isfinite(dt64)

    def test_isfinite_pandas_period_index(self):
        daily = pd.date_range("2017-1-1", "2017-1-3", freq="D").to_period("D")
        assert_data_equal(isfinite(daily), np.array([True, True, True]))

    def test_isfinite_pandas_period_series(self):
        daily = pd.date_range("2017-1-1", "2017-1-3", freq="D").to_period("D").to_series()
        assert_data_equal(isfinite(daily), np.array([True, True, True]))

    def test_isfinite_pandas_period_index_nat(self):
        daily = pd.date_range("2017-1-1", "2017-1-3", freq="D").to_period("D")
        daily = pd.PeriodIndex([*daily, pd.NaT])
        assert_data_equal(isfinite(daily), np.array([True, True, True, False]))

    def test_isfinite_pandas_period_series_nat(self):
        daily = pd.date_range("2017-1-1", "2017-1-3", freq="D").to_period("D")
        daily = pd.Series([*daily, pd.NaT])
        assert_data_equal(isfinite(daily), np.array([True, True, True, False]))

    def test_isfinite_pandas_timestamp_index(self):
        daily = pd.date_range("2017-1-1", "2017-1-3", freq="D")
        assert_data_equal(isfinite(daily), np.array([True, True, True]))

    def test_isfinite_pandas_timestamp_series(self):
        daily = pd.date_range("2017-1-1", "2017-1-3", freq="D").to_series()
        assert_data_equal(isfinite(daily), np.array([True, True, True]))

    def test_isfinite_pandas_timestamp_index_nat(self):
        daily = pd.date_range("2017-1-1", "2017-1-3", freq="D")
        daily = pd.DatetimeIndex([*daily, pd.NaT])
        assert_data_equal(isfinite(daily), np.array([True, True, True, False]))

    def test_isfinite_pandas_timestamp_series_nat(self):
        daily = pd.date_range("2017-1-1", "2017-1-3", freq="D")
        daily = pd.Series([*daily, pd.NaT])
        assert_data_equal(isfinite(daily), np.array([True, True, True, False]))

    def test_isfinite_datetime64_array(self):
        dt64 = np.array([np.datetime64(datetime.datetime(2017, 1, i)) for i in range(1, 4)])
        assert_data_equal(isfinite(dt64), np.array([True, True, True]))

    def test_isfinite_datetime64_array_with_nat(self):
        dts = [np.datetime64(datetime.datetime(2017, 1, i)) for i in range(1, 4)]
        dt64 = np.array([*dts, np.datetime64("NaT")])
        assert_data_equal(isfinite(dt64), np.array([True, True, True, False]))

    def test_isfinite_masked_numpy(self):
        masked = np.ma.core.MaskedArray([1, 2, 3], mask=[False, True, False])
        assert isinstance(masked, masked_types)
        assert list(isfinite(masked)) == [True, False, True]

    def test_isfinite_masked_pandas(self):
        masked = pd.array([1, None, 3], dtype="Int64")
        assert isinstance(masked, masked_types)
        assert list(isfinite(masked)) == [True, False, True]


class TestComputeEdges:
    """
    Tests for compute_edges function.
    """

    def setup_method(self):
        self.array1 = [0.5, 1.5, 2.5]
        self.array2 = [0.5, 1.0000001, 1.5]
        self.array3 = [1, 2, 4]

    def test_simple_edges(self):
        assert_data_equal(compute_edges(self.array1), np.array([0, 1, 2, 3]))

    def test_close_edges(self):
        assert_data_equal(compute_edges(self.array2), np.array([0.25, 0.75, 1.25, 1.75]))

    def test_uneven_edges(self):
        assert_data_equal(compute_edges(self.array3), np.array([0.5, 1.5, 3.0, 5.0]))


class TestCrossIndex:
    def setup_method(self):
        self.values1 = ["A", "B", "C"]
        self.values2 = [1, 2, 3, 4]
        self.values3 = ["?", "!"]
        self.values4 = ["x"]

    def test_cross_index_full_product(self):
        values = [self.values1, self.values2, self.values3, self.values4]
        cross_product = list(product(*values))
        for i, p in enumerate(cross_product):
            assert cross_index(values, i) == p

    def test_cross_index_depth_1(self):
        values = [self.values1]
        cross_product = list(product(*values))
        for i, p in enumerate(cross_product):
            assert cross_index(values, i) == p

    def test_cross_index_depth_2(self):
        values = [self.values1, self.values2]
        cross_product = list(product(*values))
        for i, p in enumerate(cross_product):
            assert cross_index(values, i) == p

    def test_cross_index_depth_3(self):
        values = [self.values1, self.values2, self.values3]
        cross_product = list(product(*values))
        for i, p in enumerate(cross_product):
            assert cross_index(values, i) == p

    def test_cross_index_large(self):
        values = [
            [chr(65 + i) for i in range(26)],
            list(range(500)),
            [chr(97 + i) for i in range(26)],
            [chr(48 + i) for i in range(10)],
        ]
        assert cross_index(values, 50001) == ("A", 192, "i", "1")
        assert cross_index(values, 500001) == ("D", 423, "c", "1")


class TestClosestMatch:
    def test_complete_match_overlay(self):
        specs = [(0, ("Curve", "Curve", "I")), (1, ("Points", "Points", "I"))]
        spec = ("Curve", "Curve", "I")
        assert closest_match(spec, specs) == 0
        spec = ("Points", "Curve", "I")
        assert closest_match(spec, specs) == 1

    def test_partial_match_overlay(self):
        specs = [(0, ("Curve", "Curve", "I")), (1, ("Points", "Points", "I"))]
        spec = ("Curve", "Curve")
        assert closest_match(spec, specs) == 0
        spec = ("Points", "Points")
        assert closest_match(spec, specs) == 1

    def test_partial_mismatch_overlay(self):
        specs = [(0, ("Curve", "Curve", "I")), (1, ("Points", "Points", "I"))]
        spec = ("Curve", "Foo", "II")
        assert closest_match(spec, specs) == 0
        spec = ("Points", "Bar", "III")
        assert closest_match(spec, specs) == 1

    def test_no_match_overlay(self):
        specs = [(0, ("Curve", "Curve", "I")), (1, ("Points", "Points", "I"))]
        spec = ("Scatter", "Points", "II")
        assert closest_match(spec, specs) is None
        spec = ("Path", "Curve", "III")
        assert closest_match(spec, specs) is None

    def test_complete_match_ndoverlay(self):
        spec = ("Points", "Points", "", 1)
        specs = [
            (0, ("Points", "Points", "", 0)),
            (1, ("Points", "Points", "", 1)),
            (2, ("Points", "Points", "", 2)),
        ]
        assert closest_match(spec, specs) == 1
        spec = ("Points", "Points", "", 2)
        assert closest_match(spec, specs) == 2

    def test_partial_match_ndoverlay(self):
        spec = ("Points", "Points", "", 5)
        specs = [
            (0, ("Points", "Points", "", 0)),
            (1, ("Points", "Points", "", 1)),
            (2, ("Points", "Points", "", 2)),
        ]
        assert closest_match(spec, specs) == 2
        spec = ("Points", "Points", "Bar", 5)
        assert closest_match(spec, specs) == 0
        spec = ("Points", "Foo", "Bar", 5)
        assert closest_match(spec, specs) == 0

    def test_no_match_ndoverlay(self):
        specs = [
            (0, ("Points", "Points", "", 0)),
            (1, ("Points", "Points", "", 1)),
            (2, ("Points", "Points", "", 2)),
        ]
        spec = ("Scatter", "Points", "", 5)
        assert closest_match(spec, specs) is None
        spec = ("Scatter", "Bar", "Foo", 5)
        assert closest_match(spec, specs) is None
        spec = ("Scatter", "Foo", "Bar", 5)
        assert closest_match(spec, specs) is None


def test_search_indices_dtype_object():
    values = np.array(["c0", "c0", np.nan], dtype=object)
    source = np.array(["c0", np.nan], dtype=object)
    search_indices(values, source)


def test_unique_array_categorial():
    ser = pd.Series(np.random.choice(["a", "b", "c"], 100)).astype("category")
    res = unique_array([ser])
    assert sorted(res) == ["a", "b", "c"]


def test_is_nan():
    assert is_nan(np.nan) == True
    assert is_nan(None) == True
    assert is_nan(pd.NA) == True
    assert is_nan(pd.NaT) == True
    assert is_nan([1, 1]) == False
    assert is_nan([np.nan]) == True
    assert is_nan([np.nan, np.nan]) == False


def test_is_null_or_na_scalar():
    assert is_null_or_na_scalar(np.nan)
    assert is_null_or_na_scalar(pd.NA)
    assert is_null_or_na_scalar(pd.NaT)
    assert is_null_or_na_scalar(None)
    assert is_null_or_na_scalar(np.datetime64("NAT"))

    assert not is_null_or_na_scalar(datetime.datetime.today())
    assert not is_null_or_na_scalar(pd.Timestamp.now())
    assert not is_null_or_na_scalar("AAAA")
    assert not is_null_or_na_scalar(...)
    assert not is_null_or_na_scalar([1, 2])
    assert not is_null_or_na_scalar((1, 2))
    assert not is_null_or_na_scalar({1, 2})
    assert not is_null_or_na_scalar({"a": 1, "b": 2})
    assert not is_null_or_na_scalar(slice(None))
    assert not is_null_or_na_scalar(np.array([1, 2]))
    assert not is_null_or_na_scalar(pd.DataFrame([1, 2]))


def test_is_null_or_na_scalar_polars():
    pl = pytest.importorskip("polars")
    assert is_null_or_na_scalar(pl.Null)
    assert not is_null_or_na_scalar(pl.DataFrame([1, 2]))
    assert not is_null_or_na_scalar(pl.LazyFrame([1, 2]))


@pytest.mark.parametrize(
    ("data", "dtype", "expected_kind"),
    [
        # Boolean
        ([True, False, True], "bool", "b"),
        # Integer types
        ([1, 2, 3], "int8", "i"),
        ([1, 2, 3], "int16", "i"),
        ([1, 2, 3], "int32", "i"),
        ([1, 2, 3], "int64", "i"),
        ([1, 2, 3], "uint8", "u"),
        ([1, 2, 3], "uint16", "u"),
        ([1, 2, 3], "uint32", "u"),
        ([1, 2, 3], "uint64", "u"),
        # Float types
        pytest.param(
            [1.1, 2.2, 3.3],
            "float16",
            "f",
            marks=pytest.mark.xfail(reason="narwhals don't support float16"),
        ),
        ([1.1, 2.2, 3.3], "float32", "f"),
        ([1.1, 2.2, 3.3], "float64", "f"),
        # Datetime and timedelta
        (pd.to_datetime(["2021-01-01", "2021-01-02", "2021-01-03"]), None, "M"),
        (pd.to_timedelta(["1 days", "2 days", "3 days"]), None, "m"),
        # Categorical
        (pd.Categorical(["A", "B", "A"]), None, "O"),
        # Object (mixed types)
        ([1, "a", None], None, "O"),
        # String types
        pytest.param(
            ["x", "y", "z"],
            "string[python]",
            "U",
            marks=pytest.mark.xfail(reason="pandas dtype is object"),
        ),
        pytest.param(
            ["x", "y", "z"],
            "object",
            "O",
            marks=pytest.mark.xfail(reason="narwhals dtype is string"),
        ),
    ],
)
def test_dtype_kind_pandas_narwhals_consistency(data, dtype, expected_kind):
    """Test dtype_kind gives same results for pandas and narwhals DataFrames"""
    pd_df = pd.DataFrame({"col": data})
    if dtype is not None:
        pd_df = pd_df.astype({"col": dtype})
    nw_df = nw.from_native(pd_df)

    pd_kind = dtype_kind(pd_df["col"])
    nw_kind = dtype_kind(nw_df["col"])

    assert pd_kind == expected_kind
    assert nw_kind == expected_kind


def test_dtype_kind_usage_count():
    holoviews_path = Path(__file__).parents[2]
    file_counts = {}
    for py_file in holoviews_path.rglob("*.py"):
        rel_path = py_file.relative_to(holoviews_path)
        if "__pycache__" in rel_path.parts or "tests" in rel_path.parts:
            continue

        with open(py_file, encoding="utf-8") as f:
            content = f.read()
            count = content.count("dtype.kind")

        if count > 0:
            file_counts[str(rel_path).replace(os.sep, "/")] = count

    expected_files = {"core/util/__init__.py": 1}
    assert file_counts == expected_files, "Don't use dtype.kind, use dtype_kind"


@with_and_without_pandas
@pytest.mark.parametrize(
    ("test_input", "expected_output"),
    [
        (np.array([3, 1, 2, 1, 3, 4, 2]), np.array([3, 1, 2, 4])),
        (np.array([1, 2, 3, 4, 5]), np.array([1, 2, 3, 4, 5])),
        (np.array([5, 5, 5, 5]), np.array([5])),
        (np.array([42]), np.array([42])),
        (np.array(["b", "a", "c", "a", "b"]), np.array(["b", "a", "c"])),
        (np.array([1.5, 2.3, 1.5, 3.7, 2.3]), np.array([1.5, 2.3, 3.7])),
        (
            np.array([1, "a", 2, "a", 1, "b"], dtype=object),
            np.array([1, "a", 2, "b"], dtype=object),
        ),
        (np.array([]), np.array([])),
    ],
    ids=[
        "numeric_with_duplicates",
        "already_unique",
        "all_same",
        "single_element",
        "string_array",
        "float_array",
        "object_array_mixed_types",
        "empty_array",
    ],
)
def test_unique(test_input, expected_output, with_pandas, monkeypatch):
    result = util._unique(test_input)
    np.testing.assert_array_equal(result, expected_output)


@with_and_without_pandas
@pytest.mark.parametrize(
    ("test_input", "expected_output"),
    [
        (np.datetime64("2023-01-15T12:30:45"), np.datetime64("2023-01-15T12:30:45")),
        (datetime.datetime(2023, 1, 15, 12, 30, 45), np.datetime64("2023-01-15T12:30:45", "ns")),
        (datetime.date(2023, 1, 15), np.datetime64("2023-01-15T00:00:00", "ns")),
        ("2023-01-15T12:30:45", np.datetime64("2023-01-15T12:30:45", "ns")),
        ("2023-01-15", np.datetime64("2023-01-15T00:00:00", "ns")),
        ("2023/01/15", np.datetime64("2023-01-15T00:00:00", "ns")),
        (
            datetime.datetime(2023, 1, 15, 12, 30, 45, tzinfo=datetime.timezone.utc),
            np.datetime64("2023-01-15T12:30:45", "ns"),
        ),
        (
            datetime.datetime(
                2023, 1, 15, 17, 30, 45, tzinfo=datetime.timezone(datetime.timedelta(hours=5))
            ),
            np.datetime64("2023-01-15T12:30:45", "ns"),
        ),
    ],
    ids=[
        "numpy_datetime64",
        "python_datetime",
        "python_date",
        "string_iso_format",
        "string_simple_date",
        "string_slash_format",
        "timezone_aware",
        "timezone_conversion",
    ],
)
def test_parse_datetime(test_input, expected_output, with_pandas, monkeypatch):
    result = util.parse_datetime(test_input)
    assert isinstance(result, np.datetime64)
    assert result == expected_output


def test_isdatetime_pyarrow():
    pytest.importorskip("pyarrow")
    ser = pd.to_datetime(["2024-01-01", "2024-01-02"]).astype("date32[pyarrow]")
    assert isinstance(ser.dtype, pd.core.dtypes.dtypes.ArrowDtype)
    assert isdatetime(ser)
