import time
from types import ModuleType

import pytest


def test_ulid_now(impl):
    ulid_str = impl.ulid_now()
    assert len(ulid_str) == 26
    timestamp = impl.ulid_to_timestamp(ulid_str)
    assert timestamp == pytest.approx(int(time.time() * 1000), 1)


def test_ulid_now_bytes(impl):
    ulid_bytes = impl.ulid_now_bytes()
    assert len(ulid_bytes) == 16
    timestamp = impl.ulid_to_timestamp(ulid_bytes)
    assert timestamp == pytest.approx(int(time.time() * 1000), 1)


def test_ulid_hex(impl):
    ulid_str = impl.ulid_hex()
    assert len(ulid_str) == 32


def test_ulid_to_bytes(impl):
    assert (
        impl.ulid_to_bytes("01GTCKZT7K26YEVVW6AMQ3J0VT")
        == b"\x01\x86\x99?\xe8\xf3\x11\xbc\xed\xef\x86U.9\x03z"
    )


def test_ulid_to_bytes_overflow(impl):
    with pytest.raises(ValueError, match="26 character"):
        impl.ulid_to_bytes("01GTCKZT7K26YEVVW6AMQ3J0VT0000")


def test_ulid_to_bytes_under_low(impl):
    with pytest.raises(ValueError, match="26 character"):
        impl.ulid_to_bytes("01")


def test_bytes_to_ulid(impl):
    assert (
        impl.bytes_to_ulid(b"\x01\x86\x99?\xe8\xf3\x11\xbc\xed\xef\x86U.9\x03z")
        == "01GTCKZT7K26YEVVW6AMQ3J0VT"
    )


def test_ulid_to_bytes_invalid_length(impl):
    with pytest.raises(ValueError, match="26 character"):
        assert impl.ulid_to_bytes("aa")


def test_bytes_to_ulid_invalid_length(impl):
    with pytest.raises(ValueError, match="aa"):
        assert impl.bytes_to_ulid(b"aa")


def test_ulid_to_bytes_2(impl):
    assert (
        impl.ulid_to_bytes("00000000AC00GW0X476W5TVBFE")
        == b"\x00\x00\x00\x00\x01L\x00!\xc0t\x877\x0b\xad\xad\xee"
    )


def test_timestamp_string(impl):
    ulid = impl.ulid_at_time(1677627631.2127638)
    assert ulid[:10] == "01GTD6C9KC"


def test_timestamp_bytes(impl):
    ulid = impl.ulid_at_time_bytes(1677627631.2127638)
    # prefix verified with another ulid implementation (valohai/ulid2)
    assert ulid[:6] == b"\x01\x86\x9af&l"


@pytest.mark.parametrize("gen", ["ulid_at_time", "ulid_at_time_bytes"])
def test_timestamp(impl, gen):
    gen = getattr(impl, gen)
    now = time.time()
    ulid = gen(now)
    # ULIDs store time to 3 decimal places compared to python timestamps
    assert impl.ulid_to_timestamp(ulid) == int(now * 1000)


@pytest.mark.parametrize("gen", ["ulid_at_time", "ulid_at_time_bytes"])
def test_timestamp_fixed(impl, gen):
    gen = getattr(impl, gen)
    now = 1677627631.2127638
    ulid = gen(now)
    # ULIDs store time to 3 decimal places compared to python timestamps
    assert impl.ulid_to_timestamp(ulid) == int(now * 1000)


@pytest.mark.parametrize("gen", ["ulid_at_time", "ulid_at_time_bytes"])
def test_ulid_at_time_nan_raises(impl, gen):
    with pytest.raises(ValueError, match="NaN"):
        getattr(impl, gen)(float("nan"))


@pytest.mark.parametrize("gen", ["ulid_at_time", "ulid_at_time_bytes"])
@pytest.mark.parametrize("value", [float("inf"), float("-inf")])
def test_ulid_at_time_infinity_raises(impl, gen, value):
    with pytest.raises(OverflowError, match="infinity"):
        getattr(impl, gen)(value)


@pytest.mark.parametrize("gen", ["ulid_at_time", "ulid_at_time_bytes"])
@pytest.mark.parametrize(
    "value",
    [
        -1.0,
        # Timestamps in (-0.001, 0) seconds: ts * 1000 lands in (-1, 0), which
        # int() truncates toward zero. The C extension checks the sign of the
        # millisecond value *before* the cast, so it rejects these as negative;
        # the Python impl must do the same instead of silently producing ts=0.
        -0.0009,
        -0.0005,
        -0.0001,
        -1e-9,
    ],
)
def test_ulid_at_time_negative_raises(impl, gen, value):
    with pytest.raises(OverflowError, match="negative"):
        getattr(impl, gen)(value)


@pytest.mark.parametrize("gen", ["ulid_at_time", "ulid_at_time_bytes"])
def test_ulid_at_time_too_large_raises(impl, gen):
    # 2^48 ms / 1000 = 281474976710.656 seconds → ts >= that overflows.
    with pytest.raises(OverflowError, match="int too big"):
        getattr(impl, gen)(1e18)


@pytest.mark.parametrize("gen", ["ulid_at_time", "ulid_at_time_bytes"])
def test_ulid_at_time_max_valid(impl, gen):
    # Exclusive upper bound is 2^48 ms; the largest valid second-timestamp
    # is just under (2^48 - 1) / 1000. Stay safely below to avoid float
    # rounding edge cases.
    ts = 281474976710.000
    ulid = getattr(impl, gen)(ts)
    assert ulid is not None


@pytest.mark.parametrize("gen", ["ulid_at_time", "ulid_at_time_bytes"])
def test_ulid_at_time_wrong_type_raises(impl, gen):
    with pytest.raises(TypeError):
        getattr(impl, gen)("not-a-float")


def test_non_uppercase_b32_data(impl):
    assert len(impl.ulid_to_bytes("not_uppercase_b32_data_:::")) == 16


def test_ulid_to_bytes_or_none(impl):
    """Test ulid_to_bytes_or_none."""
    assert (
        impl.ulid_to_bytes_or_none("01EYQZJXZ5Z1Z1Z1Z1Z1Z1Z1Z1")
        == b"\x01w\xaf\xf9w\xe5\xf8~\x1f\x87\xe1\xf8~\x1f\x87\xe1"
    )
    assert impl.ulid_to_bytes_or_none("invalid") is None
    assert impl.ulid_to_bytes_or_none(None) is None


@pytest.mark.parametrize(
    "value",
    [
        123,
        3.14,
        [],
        (),
        {},
        b"\x01w\xaf\xf9w\xe5\xf8~\x1f\x87\xe1\xf8~\x1f\x87\xe1",  # bytes, not str
        object(),
    ],
)
def test_ulid_to_bytes_or_none_invalid_type(impl, value):
    """Non-str inputs return None instead of raising (parity with C impl)."""
    assert impl.ulid_to_bytes_or_none(value) is None


def test_ulid_to_bytes_or_none_non_ascii(impl):
    """Non-ASCII 26-char strings return None instead of raising."""
    assert impl.ulid_to_bytes_or_none("é" * 26) is None


def test_entropy(impl):
    """Verify generated ULIDs have non-trivial entropy in the random part."""
    samples = [impl.ulid_at_time_bytes(1677627631.0)[6:] for _ in range(200)]
    assert len(set(samples)) == len(samples), (
        "Generated ULIDs should have unique entropy"
    )

    for pos in range(10):
        distinct_bytes = {s[pos] for s in samples}
        assert len(distinct_bytes) > 1, f"Byte position {pos} has no variety"

    for pos in range(20):
        byte_idx, shift = divmod(pos, 2)
        distinct_nibbles = {(s[byte_idx] >> (4 * shift)) & 0xF for s in samples}
        assert len(distinct_nibbles) > 1, f"Nibble position {pos} has no variety"


def test_ulid_to_timestamp_wrong_length_bytes(impl: ModuleType) -> None:
    """Bytes input with the wrong length must raise ValueError, not silently truncate."""
    with pytest.raises(ValueError, match="16 bytes"):
        impl.ulid_to_timestamp(b"short")
    with pytest.raises(ValueError, match="16 bytes"):
        impl.ulid_to_timestamp(b"")
    with pytest.raises(ValueError, match="16 bytes"):
        impl.ulid_to_timestamp(b"a" * 50)


def test_ulid_to_timestamp_wrong_length_string(impl: ModuleType) -> None:
    """String input with the wrong length must raise ValueError."""
    with pytest.raises(ValueError, match="26 character"):
        impl.ulid_to_timestamp("too short")
    with pytest.raises(ValueError, match="26 character"):
        impl.ulid_to_timestamp("01GTCKZT7K26YEVVW6AMQ3J0VT0000")


def test_ulid_to_timestamp_wrong_type(impl: ModuleType) -> None:
    """Non-str / non-bytes input must raise TypeError."""
    with pytest.raises(TypeError):
        impl.ulid_to_timestamp(123)
    with pytest.raises(TypeError):
        impl.ulid_to_timestamp(None)
    with pytest.raises(TypeError):
        impl.ulid_to_timestamp(
            bytearray(b"\x01\x86\x99?\xe8\xf3\x11\xbc\xed\xef\x86U.9\x03z")
        )


@pytest.mark.parametrize(
    "value",
    [
        b"01GTCKZT7K26YEVVW6AMQ3J0VT",
        bytearray(b"01GTCKZT7K26YEVVW6AMQ3J0VT"),
        memoryview(b"01GTCKZT7K26YEVVW6AMQ3J0VT"),
        123,
        None,
        3.14,
    ],
)
def test_ulid_to_bytes_wrong_type(impl, value):
    """Non-str input must raise TypeError (parity with C impl)."""
    with pytest.raises(TypeError):
        impl.ulid_to_bytes(value)


@pytest.mark.parametrize(
    "value",
    [
        bytearray(16),
        memoryview(b"x" * 16),
        "01GTCKZT7K26YEVVW6AMQ3J0VT",
        123,
        None,
    ],
)
def test_bytes_to_ulid_wrong_type(impl, value):
    """Non-bytes input must raise TypeError (parity with C impl)."""
    with pytest.raises(TypeError):
        impl.bytes_to_ulid(value)


def test_bytes_to_ulid_or_none(impl):
    """Test bytes_to_ulid_or_none."""
    assert (
        impl.bytes_to_ulid_or_none(
            b"\x01w\xaf\xf9w\xe5\xf8~\x1f\x87\xe1\xf8~\x1f\x87\xe1"
        )
        == "01EYQZJXZ5Z1Z1Z1Z1Z1Z1Z1Z1"
    )
    assert impl.bytes_to_ulid_or_none(b"invalid") is None
    assert impl.bytes_to_ulid_or_none(None) is None


@pytest.mark.parametrize(
    "value",
    [
        123,
        3.14,
        [],
        (),
        {},
        "01EYQZJXZ5Z1Z1Z1Z1Z1Z1Z1Z1",  # str, not bytes
        object(),
    ],
)
def test_bytes_to_ulid_or_none_invalid_type(impl, value):
    """Non-bytes inputs return None instead of raising (parity with C impl)."""
    assert impl.bytes_to_ulid_or_none(value) is None


@pytest.mark.parametrize(
    "value",
    ["", "short", "01GTCKZT7K26YEVVW6AMQ3J0VT ", "01GTCKZT7K26YEVVW6AMQ3J0VT0000"],
)
def test_ulid_to_bytes_error_message_reprs_value(impl: ModuleType, value: str) -> None:
    """The wrong-length ValueError message repr-quotes the offending value.

    Both impls must embed repr(value) (the C extension uses PyErr_Format's %R),
    so a trailing space or empty string is unambiguous in the message and the
    two implementations stay byte-for-byte aligned.
    """
    with pytest.raises(ValueError, match="26 character") as exc_info:
        impl.ulid_to_bytes(value)
    assert repr(value) in str(exc_info.value)


@pytest.mark.parametrize(
    "value",
    ["", "short", "01GTCKZT7K26YEVVW6AMQ3J0VT ", "01GTCKZT7K26YEVVW6AMQ3J0VT0000"],
)
def test_ulid_to_timestamp_error_message_reprs_value(
    impl: ModuleType, value: str
) -> None:
    """ulid_to_timestamp's string path inherits the repr-quoted ValueError.

    It delegates to ulid_to_bytes, so the wrong-length message must embed
    repr(value) on both impls just like the ulid_to_bytes path.
    """
    with pytest.raises(ValueError, match="26 character") as exc_info:
        impl.ulid_to_timestamp(value)
    assert repr(value) in str(exc_info.value)
