"""Unit tests for ``chunking_by_recursive_character`` (process_options=R)."""

import pytest

pytest.importorskip("langchain_text_splitters")
from langchain_text_splitters import RecursiveCharacterTextSplitter  # noqa: E402

from lightrag.chunker import chunking_by_recursive_character  # noqa: E402
from lightrag.chunker.recursive_character import _split_text_with_spans  # noqa: E402
from lightrag.utils import Tokenizer, TokenizerInterface  # noqa: E402


class _CharTokenizer(TokenizerInterface):
    """1 char ≈ 1 token; lets assertions reason in terms of input length."""

    def encode(self, content: str):
        return [ord(ch) for ch in content]

    def decode(self, tokens):
        return "".join(chr(t) for t in tokens)


def _tok() -> Tokenizer:
    return Tokenizer("char-tokenizer", _CharTokenizer())


@pytest.mark.offline
def test_empty_input_returns_empty_list():
    chunks = chunking_by_recursive_character(_tok(), "")
    assert chunks == []


@pytest.mark.offline
def test_short_input_single_chunk():
    body = "Para A.\n\nPara B."
    chunks = chunking_by_recursive_character(_tok(), body, chunk_token_size=1000)

    assert len(chunks) == 1
    assert chunks[0]["content"] == body
    assert chunks[0]["_source_span"] == {"start": 0, "end": len(body)}
    assert chunks[0]["chunk_order_index"] == 0
    assert chunks[0]["tokens"] == len(body)


@pytest.mark.offline
def test_paragraph_separator_used_first():
    """``\\n\\n`` is the first separator in the default cascade — three
    paragraphs that each fit under the cap should split exactly there."""
    body = "Alpha section.\n\nBeta section.\n\nGamma section."
    chunks = chunking_by_recursive_character(
        _tok(),
        body,
        chunk_token_size=20,
        chunk_overlap_token_size=0,
    )

    assert [c["chunk_order_index"] for c in chunks] == list(range(len(chunks)))
    assert all(c["content"].strip() for c in chunks)
    # Reconstructed (joined with the splitter's separator semantics) must
    # at least contain each original paragraph as a substring.
    joined = "\n\n".join(c["content"] for c in chunks)
    for para in ("Alpha section.", "Beta section.", "Gamma section."):
        assert para in joined


@pytest.mark.offline
def test_token_field_matches_tokenizer_encode_length():
    chunks = chunking_by_recursive_character(
        _tok(),
        "X" * 50 + "\n\n" + "Y" * 50,
        chunk_token_size=40,
        chunk_overlap_token_size=5,
    )
    tok = _tok()
    for c in chunks:
        assert c["tokens"] == len(tok.encode(c["content"]))


@pytest.mark.offline
def test_custom_separators_are_honored():
    body = "alpha|beta|gamma|delta"
    chunks = chunking_by_recursive_character(
        _tok(),
        body,
        chunk_token_size=10,
        chunk_overlap_token_size=0,
        separators=["|", ""],
    )
    contents = [c["content"] for c in chunks]
    # With "|" as the primary separator and a 10-token cap, each 5-char
    # token name must land in its own chunk.
    assert any("alpha" in c for c in contents)
    assert any("delta" in c for c in contents)
    # Every chunk fits the cap.
    for c in chunks:
        assert c["tokens"] <= 10


@pytest.mark.offline
def test_recursive_chunks_carry_exact_source_spans_with_overlap():
    body = "Alpha section.\n\nBeta section.\n\nGamma section."
    chunks = chunking_by_recursive_character(
        _tok(),
        body,
        chunk_token_size=22,
        chunk_overlap_token_size=6,
    )

    assert len(chunks) >= 2
    for chunk in chunks:
        span = chunk["_source_span"]
        assert body[span["start"] : span["end"]] == chunk["content"]


@pytest.mark.offline
def test_long_unique_text_keeps_every_source_span():
    """Every chunk of a long, non-repeating document must carry a span.

    Regression for the LangChain ``add_start_index`` unit mismatch: with a
    token-based length function its search cursor overshot each chunk's true
    start, dropping ``_source_span`` (and failing span-first backfill) on
    roughly half of a unique document's chunks.
    """
    body = " ".join(f"word{i}" for i in range(800))
    chunks = chunking_by_recursive_character(
        _tok(),
        body,
        chunk_token_size=50,
        chunk_overlap_token_size=10,
    )

    assert len(chunks) > 5
    for chunk in chunks:
        span = chunk["_source_span"]
        assert body[span["start"] : span["end"]] == chunk["content"]


@pytest.mark.offline
def test_repeated_text_spans_tile_whole_document():
    """Spans over heavily repeated text must tile the document, not cluster.

    Regression for the repeated-content ambiguity: a naive forward ``find``
    matches every identical overlapping chunk to the nearest repetition, so all
    spans collapse into the document head and the tail gets no provenance. The
    offset-aware splitter mirror must instead advance spans across the whole text.
    """
    unit = "ABCDE fghij. "
    body = unit * 60
    chunks = chunking_by_recursive_character(
        _tok(),
        body,
        chunk_token_size=40,
        chunk_overlap_token_size=10,
    )

    spans = [c["_source_span"] for c in chunks]
    assert len(spans) == len(chunks)  # no chunk lost its span

    # Each span is exact and starts are monotonically non-decreasing.
    for chunk, span in zip(chunks, spans):
        assert body[span["start"] : span["end"]] == chunk["content"]
    starts = [s["start"] for s in spans]
    assert starts == sorted(starts)

    # Spans reach the document tail rather than clustering at the head. Under
    # the old nearest-repetition behaviour the furthest end stalled near the
    # first few hundred chars; tiling reaches within one ``unit`` of the end.
    assert max(s["end"] for s in spans) >= len(body) - len(unit)


@pytest.mark.offline
def test_span_mirror_matches_langchain_split_text():
    """Drift guard for the offset-aware mirror of LangChain's splitter."""
    body = (
        "Alpha repeats.\n\n"
        "ff hh aa hh ee\n"
        "ff hh aa hh ee\n\n"
        "Tail repeats. Tail repeats. Tail repeats."
    )
    tok = _tok()

    def length_function(text: str) -> int:
        return len(tok.encode(text))

    splitter = RecursiveCharacterTextSplitter(
        chunk_size=36,
        chunk_overlap=14,
        length_function=length_function,
        strip_whitespace=True,
    )

    mirrored = _split_text_with_spans(
        body,
        base_offset=0,
        separators=splitter._separators,
        chunk_size=splitter._chunk_size,
        chunk_overlap=splitter._chunk_overlap,
        length_function=length_function,
        keep_separator=splitter._keep_separator,
        is_separator_regex=splitter._is_separator_regex,
        strip_whitespace=splitter._strip_whitespace,
    )

    assert [piece for piece, _, _ in mirrored] == splitter.split_text(body)
    for piece, start, end in mirrored:
        assert body[start:end] == piece


@pytest.mark.offline
def test_chunk_repeating_an_earlier_block_maps_to_its_own_block():
    """A chunk whose text also appears in an earlier, different block must map
    to *its own* occurrence, not the earlier copy.

    Regression for cross-block duplicate provenance: merged text ``"ff aa\\n\\naa"``
    splits into ``["ff aa", "aa"]``. The second chunk ``"aa"`` is block 2, so its
    span must point at the *second* ``"aa"`` (offset 7), not the ``"aa"`` inside
    ``"ff aa"`` (offset 3). The earlier locator anchored on a predicted offset
    and snapped to the nearest copy, silently emitting the wrong — but
    legal-looking — span that strict backfill would trust.
    """
    merged = "ff aa\n\naa"
    chunks = chunking_by_recursive_character(
        _tok(),
        merged,
        chunk_token_size=5,
        chunk_overlap_token_size=1,
    )

    assert [c["content"] for c in chunks] == ["ff aa", "aa"]
    assert chunks[0]["_source_span"] == {"start": 0, "end": 5}
    # The second "aa" lives in block 2 at offset 7, after the "\n\n" separator —
    # not the "aa" embedded in "ff aa" at offset 3.
    assert chunks[1]["_source_span"] == {"start": 7, "end": 9}
    assert merged[7:9] == "aa"


@pytest.mark.offline
def test_repeated_block_window_does_not_shift_to_previous_duplicate():
    """A repeated multi-block chunk must not slide back into the prior duplicate."""
    block = "ff hh aa hh ee"
    merged = "\n\n".join([block] * 4)
    chunks = chunking_by_recursive_character(
        _tok(),
        merged,
        chunk_token_size=36,
        chunk_overlap_token_size=14,
    )

    assert [c["content"] for c in chunks] == [
        f"{block}\n\n{block}",
        f"{block}\n\n{block}",
    ]
    assert chunks[0]["_source_span"] == {"start": 0, "end": 30}
    assert chunks[1]["_source_span"] == {"start": 32, "end": 62}
