"""Tests for the paragraph-semantic ``drop_references`` option (chunking=P).

A reference block is dropped only when it BOTH sits within the last
``references_tail_n`` content blocks AND its heading matches a reference prefix.
The switch is the only knob that flows through ``chunk_options``; the tail
window and heading prefixes are read live from env at chunk time (verified here
by mutating env between calls, proving they are NOT snapshotted).

Assertions check the *content* of the emitted chunks (each block carries a
distinctive marker) rather than heading names, because LevelMerge may merge
small blocks and rewrite the surviving heading.
"""

import json
import logging

import pytest

from lightrag.chunker.paragraph_semantic import (
    _format_dropped_headings,
    _is_reference_heading,
    chunking_by_paragraph_semantic,
)
from lightrag.constants import DEFAULT_P_REFERENCES_HEADINGS
from lightrag.utils import Tokenizer, TokenizerInterface, logger as _lightrag_logger


class _CharTokenizer(TokenizerInterface):
    """1:1 character-to-token mapping — keeps math obvious in assertions."""

    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 _make_tokenizer() -> Tokenizer:
    return Tokenizer(model_name="char", tokenizer=_CharTokenizer())


def _row(heading: str, content: str, *, level: int = 1) -> dict:
    return {
        "type": "content",
        "blockid": heading or "blk",
        "format": "plain_text",
        "content": content,
        "heading": heading,
        "parent_headings": [],
        "level": level,
        "session_type": "body",
        "table_slice": "none",
        "positions": [],
    }


def _write_blocks_jsonl(tmp_path, rows: list[dict]) -> str:
    path = tmp_path / "doc.blocks.jsonl"
    path.write_text(
        "\n".join(json.dumps(row, ensure_ascii=False) for row in rows),
        encoding="utf-8",
    )
    return str(path)


def _all_content(chunks: list[dict]) -> str:
    return "\n".join(c["content"] for c in chunks)


# --------------------------------------------------------------------------- #
# _is_reference_heading (word boundary vs. plain CJK prefix)
# --------------------------------------------------------------------------- #


@pytest.mark.parametrize(
    "heading,expected",
    [
        ("References", True),
        ("references", True),  # case-insensitive
        ("REFERENCES", True),
        ("Bibliography", True),
        ("References [1-50]", True),  # word boundary: next char is non-alnum
        ("参考文献", True),
        ("参考文献列表", True),  # CJK: plain prefix, no word boundary
        ("Referenced work", False),  # ASCII word boundary excludes this
        ("Related Work", False),
        ("", False),
    ],
)
def test_is_reference_heading(heading, expected):
    assert _is_reference_heading(heading, DEFAULT_P_REFERENCES_HEADINGS) is expected


# --------------------------------------------------------------------------- #
# _format_dropped_headings (length-bounded log rendering)
# --------------------------------------------------------------------------- #


def test_format_dropped_headings_short_list():
    assert _format_dropped_headings(["References"]) == "'References'"
    assert (
        _format_dropped_headings(["References", "Bibliography"])
        == "'References', 'Bibliography'"
    )


def test_format_dropped_headings_truncates_long_heading():
    out = _format_dropped_headings(["X" * 200], max_each=60)
    # 60 kept chars + an ellipsis, all within one repr'd string.
    assert "X" * 60 + "…" in out
    assert "X" * 61 not in out


def test_format_dropped_headings_caps_item_count():
    out = _format_dropped_headings([f"H{i}" for i in range(12)], max_items=5)
    assert out.count("'H") == 5  # only 5 listed
    assert "(+7 more)" in out


# --------------------------------------------------------------------------- #
# Filtering behaviour (assert on content markers, robust to LevelMerge)
# --------------------------------------------------------------------------- #


@pytest.mark.offline
def test_drops_trailing_reference_block(tmp_path):
    tokenizer = _make_tokenizer()
    rows = [
        _row("Introduction", "INTRO_MARKER intro body"),
        _row("Method", "METHOD_MARKER method body"),
        _row("References", "REF_MARKER [1] Foo. [2] Bar."),
    ]
    blocks_path = _write_blocks_jsonl(tmp_path, rows)

    body = _all_content(
        chunking_by_paragraph_semantic(
            tokenizer, "", 2000, blocks_path=blocks_path, drop_references=True
        )
    )
    assert "REF_MARKER" not in body
    assert "INTRO_MARKER" in body and "METHOD_MARKER" in body


@pytest.mark.offline
def test_default_keeps_reference_block(tmp_path):
    tokenizer = _make_tokenizer()
    rows = [
        _row("Introduction", "INTRO_MARKER intro body"),
        _row("References", "REF_MARKER [1] Foo."),
    ]
    blocks_path = _write_blocks_jsonl(tmp_path, rows)

    # drop_references defaults to False — references survive.
    body = _all_content(
        chunking_by_paragraph_semantic(tokenizer, "", 2000, blocks_path=blocks_path)
    )
    assert "REF_MARKER" in body


@pytest.mark.offline
def test_mid_document_reference_section_outside_window_kept(tmp_path):
    tokenizer = _make_tokenizer()
    # A "References" heading mid-document, with two non-reference blocks after
    # it, so it falls outside the last-2 window and must NOT be dropped.
    rows = [
        _row("Introduction", "INTRO_MARKER intro body"),
        _row("References", "REF_MARKER discusses references"),
        _row("Method", "METHOD_MARKER method body"),
        _row("Results", "RESULTS_MARKER results body"),
    ]
    blocks_path = _write_blocks_jsonl(tmp_path, rows)

    body = _all_content(
        chunking_by_paragraph_semantic(
            tokenizer,
            "",
            2000,
            blocks_path=blocks_path,
            drop_references=True,
            references_tail_n=2,
        )
    )
    assert "REF_MARKER" in body


@pytest.mark.offline
def test_word_boundary_keeps_referenced(tmp_path):
    tokenizer = _make_tokenizer()
    rows = [
        _row("Introduction", "INTRO_MARKER intro body"),
        _row("参考文献列表", "CJKREF_MARKER [1] Foo."),
        _row("Referenced datasets", "DATASET_MARKER dataset details"),
    ]
    blocks_path = _write_blocks_jsonl(tmp_path, rows)

    body = _all_content(
        chunking_by_paragraph_semantic(
            tokenizer, "", 2000, blocks_path=blocks_path, drop_references=True
        )
    )
    # CJK prefix block dropped; "Referenced ..." kept (ASCII word boundary).
    assert "CJKREF_MARKER" not in body
    assert "DATASET_MARKER" in body


@pytest.mark.offline
def test_all_blocks_match_keeps_to_avoid_empty(tmp_path):
    tokenizer = _make_tokenizer()
    rows = [
        _row("References", "REF1_MARKER [1] Foo."),
        _row("Bibliography", "REF2_MARKER [2] Bar."),
    ]
    blocks_path = _write_blocks_jsonl(tmp_path, rows)

    # Both blocks match within the last-2 window; dropping all would leave no
    # content, so the chunker keeps them and warns instead.
    chunks = chunking_by_paragraph_semantic(
        tokenizer, "", 2000, blocks_path=blocks_path, drop_references=True
    )
    body = _all_content(chunks)
    assert chunks  # not an empty document
    assert "REF1_MARKER" in body and "REF2_MARKER" in body


# --------------------------------------------------------------------------- #
# Detection knobs are read LIVE from env (not snapshotted)
# --------------------------------------------------------------------------- #


@pytest.mark.offline
def test_tail_n_read_live_from_env(tmp_path, monkeypatch):
    tokenizer = _make_tokenizer()
    rows = [
        _row("Introduction", "INTRO_MARKER intro body"),
        _row("References", "REF_MARKER [1] Foo."),
        _row("Appendix", "APPENDIX_MARKER appendix body"),
    ]
    blocks_path = _write_blocks_jsonl(tmp_path, rows)

    # tail_n=1: only the last block ("Appendix") is in the window → References
    # is outside it and survives.
    monkeypatch.setenv("CHUNK_P_REFERENCES_TAIL_N", "1")
    body = _all_content(
        chunking_by_paragraph_semantic(
            tokenizer, "", 2000, blocks_path=blocks_path, drop_references=True
        )
    )
    assert "REF_MARKER" in body

    # Widen the window to 2 → References now falls inside and is dropped. Same
    # call, only the env changed: proves the knob is read live, not snapshotted.
    monkeypatch.setenv("CHUNK_P_REFERENCES_TAIL_N", "2")
    body = _all_content(
        chunking_by_paragraph_semantic(
            tokenizer, "", 2000, blocks_path=blocks_path, drop_references=True
        )
    )
    assert "REF_MARKER" not in body


@pytest.mark.offline
def test_drop_references_emits_info_log(tmp_path, caplog):
    tokenizer = _make_tokenizer()
    rows = [
        _row("Introduction", "INTRO_MARKER intro body"),
        _row("References", "REF_MARKER [1] Foo."),
    ]
    blocks_path = _write_blocks_jsonl(tmp_path, rows)

    # The lightrag logger sets propagate=False, so caplog can't see it by
    # default — enable propagation for the duration of the call.
    original_propagate = _lightrag_logger.propagate
    _lightrag_logger.propagate = True
    try:
        with caplog.at_level(logging.INFO, logger=_lightrag_logger.name):
            chunking_by_paragraph_semantic(
                tokenizer,
                "",
                2000,
                blocks_path=blocks_path,
                drop_references=True,
                doc_id="doc-xyz",
            )
    finally:
        _lightrag_logger.propagate = original_propagate

    info_msgs = [r.getMessage() for r in caplog.records if r.levelno == logging.INFO]
    # Log names the dropped heading and attributes it to the doc_id.
    assert any("References" in m and "doc_id: doc-xyz" in m for m in info_msgs), (
        info_msgs
    )


@pytest.mark.offline
def test_headings_read_live_from_env(tmp_path, monkeypatch):
    tokenizer = _make_tokenizer()
    rows = [
        _row("Introduction", "INTRO_MARKER intro body"),
        _row("Literature", "LIT_MARKER [1] Foo. [2] Bar."),
    ]
    blocks_path = _write_blocks_jsonl(tmp_path, rows)

    # Custom prefix list makes "Literature" a reference heading.
    monkeypatch.setenv("CHUNK_P_REFERENCES_HEADINGS", "Literature|参考文献")
    body = _all_content(
        chunking_by_paragraph_semantic(
            tokenizer, "", 2000, blocks_path=blocks_path, drop_references=True
        )
    )
    assert "LIT_MARKER" not in body
