#!/usr/bin/env python3
"""Uncased UTF-8 operations: sz.utf8_uncased_fold, utf8_uncased_search, utf8_uncased_matches, utf8_uncased_order.

Mirrors the C++ test/uncased.cpp translation unit.

Covers: case folding across ASCII, Latin-1, German sharp S, ligatures, Greek, and Cyrillic scripts,
uncased substring search and the match iterator with codepoint and byte offsets, degenerate and
negative offset handling, uncased ordering, an exhaustive Unicode 17.0 CaseFolding.txt codepoint
sweep with adversarial preimage and crossing-expansion probes, and backend agreement.
Compares against: Python str.casefold, the official Unicode CaseFolding.txt rules via the
unicode_folds session fixture, and cross-backend agreement.

Run:
    uv pip install numpy pyarrow pytest pytest-repeat
    uv pip install -e . --force-reinstall --no-build-isolation
    uv run --no-project python -m pytest test/uncased.py -q
    SZ_TESTS_SEED=42 SZ_TESTS_MULTIPLIER=10 uv run --no-project python -m pytest test/uncased.py -q
"""

from random import randint

import pytest

import stringzilla as sz

from test.sz_helpers import (
    vector_width_bracketing_strings,
    SEED_VALUES,
    scale_iterations,
    seed_random_generators,
    run_across_backends,
    assert_backends_agree,
    get_random_string,
    malformed_utf8_corpus,
)

# Both haystacks below are 6 codepoints long, so a single bound list covers boundary offsets
# (0, 1, just past the end, and far out of range) for the degenerate-offset sweep.
UNCASED_DEGENERATE_HAYSTACKS = ["abcabc", "Straße"]
UNCASED_DEGENERATE_BOUNDS = [-11, -2, 0, 2, 6, 7]


# region Unit


def test_unit_utf8_uncased_fold():
    """Folds ASCII text, empty input, German sharp S, and both Str and bytes input forms to lowercase."""
    # ASCII
    assert sz.utf8_uncased_fold("HELLO") == b"hello"
    assert sz.utf8_uncased_fold("Hello World") == b"hello world"
    assert sz.utf8_uncased_fold("") == b""
    assert sz.utf8_uncased_fold("already lowercase") == b"already lowercase"

    # German sharp S expansion (ß → ss)
    assert sz.utf8_uncased_fold("Straße") == b"strasse"
    assert sz.utf8_uncased_fold("GROSSE") == b"grosse"

    # Method form on Str
    assert sz.Str("HELLO").utf8_uncased_fold() == b"hello"

    # Bytes input
    assert sz.utf8_uncased_fold(b"HELLO") == b"hello"


@pytest.mark.parametrize(
    "haystack, needle, expected",
    [
        # ASCII basic cases
        ("Hello World", "WORLD", 6),
        ("Hello World", "hello", 0),
        ("Hello World", "xyz", -1),
        ("HELLO", "hello", 0),
        ("hello", "HELLO", 0),
        ("HeLLo WoRLd", "world", 6),
        ("abcdef", "CD", 2),
        # Latin1 accented characters (C3 lead byte range)
        ("Über allen Gipfeln", "ÜBER", 0),
        ("ÜBER", "über", 0),
        ("Das schöne Mädchen", "SCHÖNE", 4),
        ("Café au lait", "CAFÉ", 0),
        ("naïve approach", "NAÏVE", 0),
        ("El niño juega", "NIÑO", 3),
        # German Eszett: ß ↔ ss (bidirectional)
        ("Straße", "STRASSE", 0),
        ("STRASSE", "straße", 0),
        ("die Straße", "STRASSE", 4),
        ("groß", "GROSS", 0),
        ("GROSS", "groß", 0),
        ("Fußball", "FUSSBALL", 0),
        # Cyrillic
        ("ПРИВЕТ", "привет", 0),
        ("привет", "ПРИВЕТ", 0),
        ("Москва столица", "МОСКВА", 0),
        ("добрый день", "ДОБРЫЙ", 0),
        # Greek
        ("ΑΒΓΔ", "αβγδ", 0),
        ("αβγδ", "ΑΒΓΔ", 0),
        ("Ελλάδα", "ΕΛΛΆΔΑ", 0),
        # Mixed scripts
        ("Hello Мир World", "МИР", 6),
        ("Café МОСКВА", "москва", 5),
        # Empty and edge cases
        ("hello", "", 0),
        ("", "x", -1),
        ("", "", 0),
        ("a", "A", 0),
        ("A", "a", 0),
    ],
)
def test_utf8_uncased_search(haystack, needle, expected):
    """Uncased search finds needles across ASCII, Latin-1, German sharp S, Cyrillic, Greek, and mixed scripts."""
    assert sz.utf8_uncased_search(haystack, needle) == expected


def test_utf8_uncased_search_method():
    """utf8_uncased_search is available as a Str method with the same semantics as the free function."""
    s = sz.Str("Hello World")
    assert s.utf8_uncased_search("WORLD") == 6
    assert s.utf8_uncased_search("hello") == 0
    assert s.utf8_uncased_search("xyz") == -1


def test_utf8_uncased_search_offsets():
    """str input returns codepoint offsets and bytes input returns byte offsets for the same match."""
    # str: codepoint offsets
    # 'Hëllo' = 5 codepoints, 'Wörld' starts at codepoint 6
    assert sz.utf8_uncased_search("Hëllo Wörld", "WÖRLD") == 6
    assert sz.utf8_uncased_search("Café", "FÉ") == 2  # C, a = 2 codepoints

    # bytes: byte offsets
    # 'Hëllo' = H(1) + ë(2) + l(1) + l(1) + o(1) + space(1) = 7 bytes
    assert sz.utf8_uncased_search("Hëllo Wörld".encode(), "WÖRLD".encode()) == 7
    assert sz.utf8_uncased_search("Café".encode(), "FÉ".encode()) == 2


def test_utf8_uncased_search_start_end():
    """start and end bounds are codepoint offsets for str input and byte offsets for bytes input."""
    # str: codepoint offsets
    text = "Hëllo Hëllo"  # 11 codepoints, 13 bytes
    assert sz.utf8_uncased_search(text, "HËLLO", 0) == 0
    assert sz.utf8_uncased_search(text, "HËLLO", 1) == 6  # Skip first
    assert sz.utf8_uncased_search(text, "HËLLO", 0, 5) == 0  # Within range
    assert sz.utf8_uncased_search(text, "HËLLO", 0, 4) == -1  # Too short

    # bytes: byte offsets
    text_bytes = text.encode()
    assert sz.utf8_uncased_search(text_bytes, "HËLLO".encode(), 0) == 0
    assert sz.utf8_uncased_search(text_bytes, "HËLLO".encode(), 1) == 7
    assert sz.utf8_uncased_search(text_bytes, "HËLLO".encode(), 0, 7) == 0
    assert sz.utf8_uncased_search(text_bytes, "HËLLO".encode(), 0, 5) == -1


def test_utf8_uncased_search_bytes():
    """Uncased search accepts bytes haystacks and needles, including a str-encoded haystack against a bytes needle."""
    assert sz.utf8_uncased_search(b"Hello World", b"WORLD") == 6
    assert sz.utf8_uncased_search(b"Strasse", b"STRASSE") == 0
    assert sz.utf8_uncased_search("Straße".encode(), b"STRASSE") == 0
    assert sz.utf8_uncased_search("XXStraße".encode(), b"STRASSE") == 2


def test_utf8_uncased_search_slicing():
    """The returned offset slices directly into the original haystack to recover the matched text."""
    text = "Café au lait"
    idx = sz.utf8_uncased_search(text, "AU")
    assert text[idx : idx + 2].lower() == "au"

    text_bytes = text.encode()
    idx = sz.utf8_uncased_search(text_bytes, b"AU")
    assert text_bytes[idx : idx + 2].lower() == b"au"


@pytest.mark.parametrize(
    "haystack, needle, expected_matches",
    [
        # ASCII - multiple matches
        ("Hello HELLO hello HeLLo", "hello", ["Hello", "HELLO", "hello", "HeLLo"]),
        ("Hello World", "world", ["World"]),
        ("Hello World", "xyz", []),
        # German ß/ss expansion
        ("Straße STRASSE strasse", "strasse", ["Straße", "STRASSE", "strasse"]),
        ("groß GROSS", "gross", ["groß", "GROSS"]),
        # Cyrillic
        ("ПРИВЕТ привет Привет", "привет", ["ПРИВЕТ", "привет", "Привет"]),
        # Greek
        ("ΑΒΓΔ αβγδ", "αβγδ", ["ΑΒΓΔ", "αβγδ"]),
        # Edge cases
        ("", "hello", []),
        ("hello", "xyz", []),
    ],
)
def test_utf8_uncased_matches(haystack, needle, expected_matches):
    """utf8_uncased_matches yields every case-insensitive match, agreeing between the free function
    and the Str method."""
    matches = [str(m) for m in sz.utf8_uncased_matches(haystack, needle)]
    assert matches == expected_matches
    # Also test method form
    matches_method = [str(m) for m in sz.Str(haystack).utf8_uncased_matches(needle)]
    assert matches_method == expected_matches


def test_utf8_uncased_matches_overlapping():
    """include_overlapping switches between overlapping and non-overlapping match counts, also with bytes input."""
    # Non-overlapping (default)
    assert len(list(sz.utf8_uncased_matches("aaaa", "aa"))) == 2
    # Overlapping
    assert len(list(sz.utf8_uncased_matches("aaaa", "aa", include_overlapping=True))) == 3
    # Bytes input
    assert len(list(sz.utf8_uncased_matches(b"Hello HELLO", b"hello"))) == 2


def test_utf8_uncased_order():
    """Uncased order compares equal, less-than, and greater-than cases across ASCII, German sharp S,
    Greek, Cyrillic, prefix ordering, and bytes input."""
    # Equal strings
    assert sz.utf8_uncased_order("hello", "HELLO") == 0
    assert sz.utf8_uncased_order("HELLO", "hello") == 0
    assert sz.utf8_uncased_order("HeLLo", "hElLO") == 0

    # German sharp S equivalence
    assert sz.utf8_uncased_order("Straße", "STRASSE") == 0
    assert sz.utf8_uncased_order("strasse", "STRAẞE") == 0

    # Less than
    assert sz.utf8_uncased_order("apple", "BANANA") < 0
    assert sz.utf8_uncased_order("APPLE", "banana") < 0
    assert sz.utf8_uncased_order("a", "B") < 0

    # Greater than
    assert sz.utf8_uncased_order("ZEBRA", "apple") > 0
    assert sz.utf8_uncased_order("zebra", "APPLE") > 0
    assert sz.utf8_uncased_order("z", "A") > 0

    # Empty strings
    assert sz.utf8_uncased_order("", "") == 0
    assert sz.utf8_uncased_order("", "a") < 0
    assert sz.utf8_uncased_order("a", "") > 0

    # Prefix ordering
    assert sz.utf8_uncased_order("hello", "HELLO WORLD") < 0
    assert sz.utf8_uncased_order("HELLO WORLD", "hello") > 0

    # Greek letters
    assert sz.utf8_uncased_order("ΑΒΓΔ", "αβγδ") == 0
    assert sz.utf8_uncased_order("αβγ", "ΑΒΓ") == 0

    # Cyrillic
    assert sz.utf8_uncased_order("ПРИВЕТ", "привет") == 0

    # Method form on Str
    s = sz.Str("hello")
    assert s.utf8_uncased_order("HELLO") == 0

    # bytes input
    assert sz.utf8_uncased_order(b"hello", b"HELLO") == 0
    assert sz.utf8_uncased_order(b"HELLO", b"hello") == 0
    assert sz.utf8_uncased_order(b"apple", b"BANANA") < 0
    assert sz.utf8_uncased_order(b"ZEBRA", b"apple") > 0

    # German sharp S with bytes
    assert sz.utf8_uncased_order("Straße".encode(), b"STRASSE") == 0
    assert sz.utf8_uncased_order(b"strasse", "Straße".encode()) == 0

    # Greek with bytes
    assert sz.utf8_uncased_order("ΑΒΓΔ".encode(), "αβγδ".encode()) == 0

    # Cyrillic with bytes
    assert sz.utf8_uncased_order("ПРИВЕТ".encode(), "привет".encode()) == 0


# endregion Unit

# region Corner cases


@pytest.mark.parametrize("haystack", UNCASED_DEGENERATE_HAYSTACKS)
@pytest.mark.parametrize("start", UNCASED_DEGENERATE_BOUNDS)
def test_utf8_uncased_search_degenerate_offsets(haystack, start):
    """For str input, negative offsets count from the end and degenerate windows report -1, matching str.find."""
    assert sz.utf8_uncased_search(haystack, "", start) >= -1  # never an out-of-range positive
    expected = haystack.find("bc", start)  # "bc" is ASCII, so .casefold() is a no-op
    assert sz.utf8_uncased_search(haystack, "BC", start) == expected


@pytest.mark.parametrize(
    "haystack, needle, start, end, expected",
    [
        ("hello", "", 6, None, -1),
        ("hello", "", 2, 1, -1),
        (b"hello", b"", 6, None, -1),
    ],
    ids=["str_start_past_end", "str_inverted_window", "bytes_start_past_end"],
)
def test_utf8_uncased_search_degenerate_offsets_report_negative_one(haystack, needle, start, end, expected):
    """Out-of-range / inverted windows with an empty needle report -1, not a clamped offset."""
    if end is None:
        assert sz.utf8_uncased_search(haystack, needle, start) == expected
    else:
        assert sz.utf8_uncased_search(haystack, needle, start, end) == expected


# endregion Corner cases

# region Conformance


@pytest.mark.parametrize(
    "input_str, expected",
    [
        ("A", b"a"),
        ("Z", b"z"),
        ("ß", b"ss"),  # German sharp S (U+00DF)
        ("ẞ", b"ss"),  # Capital sharp S (U+1E9E)
        ("ﬁ", b"fi"),  # fi ligature (U+FB01)
        ("ﬀ", b"ff"),  # ff ligature (U+FB00)
        ("ﬂ", b"fl"),  # fl ligature (U+FB02)
        ("ﬃ", b"ffi"),  # ffi ligature (U+FB03)
        ("ﬄ", b"ffl"),  # ffl ligature (U+FB04)
        ("Σ", "σ".encode("utf-8")),  # Greek Sigma
        ("Ω", "ω".encode("utf-8")),  # Greek Omega
        ("Ä", "ä".encode("utf-8")),  # German umlaut
        ("É", "é".encode("utf-8")),  # French accent
        ("Ñ", "ñ".encode("utf-8")),  # Spanish tilde
    ],
)
def test_utf8_uncased_fold_expansions(input_str, expected):
    """Folds known single- and multi-codepoint expansions, including German sharp S, ligatures, Greek Sigma and
    Omega, and accented Latin letters."""
    assert sz.utf8_uncased_fold(input_str) == expected


def test_utf8_uncased_fold_all_codepoints(unicode_folds):
    """Compare StringZilla case folding with Unicode 17.0 CaseFolding.txt rules.

    This test downloads the official Unicode 17.0 case folding data file to validate
    StringZilla's implementation, independent of Python's Unicode version.
    The file is cached in the system temp directory for subsequent runs.
    """
    mismatches = []
    missing_folds = []
    extra_folds = []

    for codepoint in range(0x110000):
        # Skip surrogates (not valid in UTF-8)
        if 0xD800 <= codepoint <= 0xDFFF:
            continue

        try:
            char = chr(codepoint)
            char_bytes = char.encode("utf-8")
            sz_folded = sz.utf8_uncased_fold(char)

            # Get expected folding from Unicode 17.0 rules
            # If not in the table, character maps to itself
            expected = unicode_folds.get(codepoint, char_bytes)

            if sz_folded != expected:
                entry = (f"U+{codepoint:04X}", repr(char), expected.hex(), sz_folded.hex())
                if codepoint in unicode_folds and sz_folded == char_bytes:
                    missing_folds.append(entry)  # StringZilla didn't fold but should have
                elif codepoint not in unicode_folds and sz_folded != char_bytes:
                    extra_folds.append(entry)  # StringZilla folded but shouldn't have
                else:
                    mismatches.append(entry)  # Both fold but to different targets
        except (ValueError, UnicodeEncodeError):
            continue

    total_errors = len(mismatches) + len(missing_folds) + len(extra_folds)
    assert total_errors == 0, (
        f"Case folding errors vs Unicode 17.0 ({len(unicode_folds)} rules): "
        f"{len(mismatches)} wrong targets, {len(missing_folds)} missing, {len(extra_folds)} extra. "
        f"First 10: {(mismatches + missing_folds + extra_folds)[:10]}"
    )


def _reference_uncased_find(haystack_bytes: bytes, needle_folded: bytes, unicode_folds: dict) -> int:
    """Reference uncased find from UCD rules: byte offset of the first
    haystack position whose codepoint folds concatenate to exactly `needle_folded`."""
    if not needle_folded:
        return 0
    text = haystack_bytes.decode("utf-8")
    codepoint_byte_offsets = []
    codepoint_folds = []
    byte_offset = 0
    for char in text:
        char_bytes = char.encode("utf-8")
        codepoint_byte_offsets.append(byte_offset)
        codepoint_folds.append(unicode_folds.get(ord(char), char_bytes))
        byte_offset += len(char_bytes)
    for start in range(len(text)):
        accumulated = b""
        for index in range(start, len(text)):
            accumulated += codepoint_folds[index]
            if len(accumulated) >= len(needle_folded):
                break
        if accumulated == needle_folded:
            return codepoint_byte_offsets[start]
    return -1


def test_utf8_uncased_search_preimages(unicode_folds):
    """Adversarial probes from Unicode 17.0 CaseFolding.txt: for every codepoint whose
    fold differs from itself, the fold output is what a user types as the needle, and
    the preimage is what hides in the haystack, placed at chunk-boundary offsets.
    """
    mismatches = []
    for codepoint in sorted(unicode_folds):
        if 0xD800 <= codepoint <= 0xDFFF:
            continue
        preimage_bytes = chr(codepoint).encode("utf-8")
        folded_bytes = unicode_folds[codepoint]
        if preimage_bytes == folded_bytes:
            continue  # Identity folds carry no signal here
        for offset in (0, 30, 62, 63, 64):
            haystack = b"y" * offset + preimage_bytes + b"tail"
            expected = _reference_uncased_find(haystack, folded_bytes, unicode_folds)
            actual = sz.utf8_uncased_search(haystack, folded_bytes)
            if actual != expected:
                mismatches.append((f"U+{codepoint:04X}", offset, expected, actual))

    assert not mismatches, (
        f"{len(mismatches)} uncased find mismatches vs Unicode 17.0 reference. "
        f"First 10 (codepoint, offset, expected, actual): {mismatches[:10]}"
    )


def _reference_uncased_find_subset(haystack_bytes: bytes, needle_folded: bytes, unicode_folds: dict) -> int:
    """Independent fold-subset reference matching `sz.utf8_uncased_search`'s documented semantics:
    fold every haystack codepoint, concatenate into one folded blob, and find the earliest byte
    offset at which `needle_folded` appears as a substring. The match neither needs to start nor end
    on a codepoint boundary, it may begin or end mid-expansion. The returned value is the byte
    offset into the original haystack of the codepoint that contains the first matched byte, or -1
    when the folded needle is absent."""
    if not needle_folded:
        return 0
    text = haystack_bytes.decode("utf-8")
    folded_blob = b""
    source_byte_offset_for_folded_byte = []  # Maps each folded byte to its source codepoint's byte offset
    byte_offset = 0
    for char in text:
        char_bytes = char.encode("utf-8")
        folded_run = unicode_folds.get(ord(char), char_bytes)
        folded_blob += folded_run
        source_byte_offset_for_folded_byte.extend([byte_offset] * len(folded_run))
        byte_offset += len(char_bytes)
    match_position = folded_blob.find(needle_folded)
    if match_position == -1:
        return -1
    return source_byte_offset_for_folded_byte[match_position]


def test_utf8_uncased_search_crossing_expansions(unicode_folds):
    """Crossing-expansion probes: matches whose folded runes span the boundary between two
    adjacent expanding codepoints. For example needle `ſß` folds to "sss" and must be found
    inside haystack `ßß`, which folds to "ssss", at offset 0; the match is not contained within
    a single haystack codepoint's expansion but crosses from one expansion into the next.
    Each case is swept across non-folding prefix paddings to exercise chunk-boundary handling.
    """
    def fold_needle(needle_str: str) -> bytes:
        """Fold a needle string into its concatenated codepoint folds, producing the
        `needle_folded` bytes that the fold-subset reference compares against."""
        folded = b""
        for char in needle_str:
            folded += unicode_folds.get(ord(char), char.encode("utf-8"))
        return folded

    # (haystack, needle) pairs whose folded forms overlap across expansion boundaries.
    crossing_cases = [
        ("ßß", "sss"),
        ("ßß", "ſß"),
        ("ẞß", "ssss"),
        ("ẞß", "sss"),
        ("ﬃ", "fi"),
        ("ﬃ", "ffi"),
        ("ﬀﬁ", "ffi"),
    ]
    filler_char = "z"  # Folds to itself, so it never participates in a match
    prefix_paddings = [0, 30, 62, 63, 64, 65]

    mismatches = []
    for haystack_str, needle_str in crossing_cases:
        needle_folded = fold_needle(needle_str)
        for padding in prefix_paddings:
            haystack_str_padded = filler_char * padding + haystack_str
            haystack_bytes = haystack_str_padded.encode("utf-8")
            needle_bytes = needle_str.encode("utf-8")
            expected = _reference_uncased_find_subset(haystack_bytes, needle_folded, unicode_folds)
            actual = sz.utf8_uncased_search(haystack_bytes, needle_bytes)
            if actual != expected:
                mismatches.append((haystack_str, needle_str, padding, expected, actual))

    assert not mismatches, (
        f"{len(mismatches)} crossing-expansion find mismatches vs Unicode 17.0 reference. "
        f"(haystack, needle, padding, expected, actual): {mismatches[:10]}"
    )


# endregion Conformance

# region Oracles


@pytest.mark.parametrize("seed_value", SEED_VALUES)
def test_utf8_uncased_fold_random_strings(seed_value: int):
    """Random ASCII-uppercase and Latin Extended strings fold identically to Python's str.casefold()."""
    seed_random_generators(seed_value)

    # Test with ASCII uppercase
    for _ in range(scale_iterations(50)):
        length = randint(1, 100)
        test_str = "".join(chr(randint(0x41, 0x5A)) for _ in range(length))  # A-Z
        python_folded = test_str.casefold().encode("utf-8")
        sz_folded = sz.utf8_uncased_fold(test_str)
        assert python_folded == sz_folded, f"Mismatch for: {test_str!r}"

    # Test with Latin Extended characters
    for _ in range(scale_iterations(50)):
        length = randint(1, 50)
        # Mix of ASCII uppercase and Latin Extended (includes ß, etc.)
        codepoints = [randint(0x41, 0x5A) for _ in range(length)]
        codepoints += [randint(0xC0, 0xFF) for _ in range(length // 2)]
        test_str = "".join(chr(cp) for cp in codepoints)
        python_folded = test_str.casefold().encode("utf-8")
        sz_folded = sz.utf8_uncased_fold(test_str)
        assert python_folded == sz_folded, f"Mismatch for: {test_str!r}"


def test_utf8_uncased_prose():
    """Case-folded search finds realistic terms that a plain byte search misses."""
    from test.utf8_helpers import PROSE_HOTEL_REVIEW, PROSE_SCIENCE_ABSTRACT, PROSE_LANGUAGE_LESSON

    cases = [
        (PROSE_HOTEL_REVIEW, "strasse"),
        (PROSE_SCIENCE_ABSTRACT, "film"),
        (PROSE_LANGUAGE_LESSON, "strasse"),
    ]
    for haystack, needle in cases:
        assert haystack.find(needle) == -1  # plain byte search misses
        result = sz.utf8_uncased_search(haystack, needle)
        assert result is not None and result >= 0


# endregion Oracles

# region Backend differential


UNCASED_FOLD_BACKEND_DIFFERENTIAL_TEXTS = [
    "hello world",
    "HELLO WORLD",
    "Straße",
    "STRASSE",
    "Café",
    "CAFÉ",
    "naïve",
    "ΑΒΓΔ",
    "αβγδ",
    "ПРИВЕТ",
    "привет",
    "ß",
    "ẞ",
    "ﬁ",
    "ﬀ",
    "ﬂ",
    "ﬃ",
    "ﬄ",
] + vector_width_bracketing_strings()

UNCASED_SEARCH_BACKEND_DIFFERENTIAL_CASES = [
    ("Hello World", "WORLD"),
    ("Straße", "STRASSE"),
    ("STRASSE", "straße"),
    ("ßß", "sss"),
    ("ẞß", "ssss"),
    ("ﬃ", "ffi"),
    ("ΑΒΓΔ", "αβγδ"),
    ("ПРИВЕТ", "привет"),
    ("Café au lait", "AU"),
]

UNCASED_ORDER_BACKEND_DIFFERENTIAL_PAIRS = [
    ("hello", "HELLO"),
    ("Straße", "STRASSE"),
    ("strasse", "STRAẞE"),
    ("apple", "BANANA"),
    ("ZEBRA", "apple"),
    ("ΑΒΓΔ", "αβγδ"),
    ("ПРИВЕТ", "привет"),
]


@pytest.mark.parametrize("text", UNCASED_FOLD_BACKEND_DIFFERENTIAL_TEXTS)
def test_utf8_uncased_fold_backend_differential(text):
    """utf8_uncased_fold must agree across every SIMD backend over ASCII, mixed-case Unicode such as Straße,
    Café, Greek, and Cyrillic, expansions such as ß and ligatures, and inputs straddling the 16/32/64-byte
    SIMD lanes."""
    assert_backends_agree(run_across_backends(lambda: sz.utf8_uncased_fold(text)), format_inputs=lambda: repr(text))


@pytest.mark.parametrize("raw", malformed_utf8_corpus())
def test_utf8_uncased_fold_backend_differential_malformed(raw):
    """Malformed UTF-8 must fold identically across every SIMD backend without crashing."""
    assert_backends_agree(run_across_backends(lambda: sz.utf8_uncased_fold(raw)), format_inputs=lambda: raw.hex())


@pytest.mark.parametrize("seed_value", SEED_VALUES)
def test_utf8_uncased_fold_backend_differential_random(seed_value):
    """Random ASCII corpora must fold identically across every SIMD backend."""
    seed_random_generators(seed_value)
    text = get_random_string()
    assert_backends_agree(run_across_backends(lambda: sz.utf8_uncased_fold(text)), format_inputs=lambda: repr(text))


@pytest.mark.parametrize("haystack, needle", UNCASED_SEARCH_BACKEND_DIFFERENTIAL_CASES)
def test_utf8_uncased_search_backend_differential(haystack, needle):
    """utf8_uncased_search must agree across every SIMD backend, including codepoint-expansion needles
    like ß folding to ss and ligatures that can cross a folded haystack's expansion boundaries."""
    assert_backends_agree(
        run_across_backends(lambda: sz.utf8_uncased_search(haystack, needle)),
        format_inputs=lambda: f"{haystack!r}/{needle!r}",
    )


@pytest.mark.parametrize("text", vector_width_bracketing_strings())
def test_utf8_uncased_search_backend_differential_vector_width(text):
    """utf8_uncased_search must agree across every SIMD backend when the match sits at a 16/32/64-byte lane
    boundary, searching for the literal padding tail planted after the multibyte codepoint."""
    assert_backends_agree(
        run_across_backends(lambda: sz.utf8_uncased_search(text, "TAIL")), format_inputs=lambda: repr(text)
    )


@pytest.mark.parametrize("raw", malformed_utf8_corpus())
def test_utf8_uncased_search_backend_differential_malformed(raw):
    """Malformed UTF-8 haystacks must search identically across every SIMD backend without crashing."""
    needle = raw[:2] if len(raw) >= 2 else raw
    assert_backends_agree(
        run_across_backends(lambda: sz.utf8_uncased_search(raw, needle)), format_inputs=lambda: raw.hex()
    )


@pytest.mark.parametrize("left, right", UNCASED_ORDER_BACKEND_DIFFERENTIAL_PAIRS)
def test_utf8_uncased_order_backend_differential(left, right):
    """utf8_uncased_order must agree across every SIMD backend, including German-sharp-S equivalence."""
    assert_backends_agree(
        run_across_backends(lambda: sz.utf8_uncased_order(left, right)), format_inputs=lambda: f"{left!r}/{right!r}"
    )


@pytest.mark.parametrize("raw", malformed_utf8_corpus())
def test_utf8_uncased_order_backend_differential_malformed(raw):
    """Malformed UTF-8 must order identically against itself across every SIMD backend without crashing."""
    assert_backends_agree(
        run_across_backends(lambda: sz.utf8_uncased_order(raw, raw)), format_inputs=lambda: raw.hex()
    )


# endregion Backend differential
