"""Paragraph Semantic Chunking for LightRAG.

Reads a LightRAG ``.blocks.jsonl`` sidecar — produced by any sidecar-emitting
parser (native / mineru / docling): heading-driven blocks, tables kept whole —
and produces a chunk list compatible with
:func:`lightrag.chunker.chunking_by_token_size`.

The full algorithm and rationale are documented in
``docs/ParagraphSemanticChunking-zh.md``. Parsers perform only heading-driven
structural splitting; all block sizing happens here. This module re-implements
the post-HeadingBlocks pipeline (TableRowSplit/AnchorSplit/LevelMerge) on top of
blocks.jsonl input, parameterised on ``chunk_token_size`` so chunk-size targets
follow the user's RAG configuration.

Pipeline:
  - HeadingBlocks — heading-driven initial split: done at parse time and
    persisted as one row per block in ``blocks.jsonl``.
  - TableRowSplit — oversized-table re-split + first/middle/last gluing,
    invoked when an embedded ``<table … format="json">`` (or ``"html"``)
    exceeds ``table_max``. Splitting prefers structural row boundaries (JSON
    list items, HTML ``<tr>`` rows) so each fragment stays a legal ``<table>``
    tag; only when no row boundary is available, or one row alone exceeds the
    cap, does it fall back to ``chunking_by_recursive_character`` on that
    fragment. When two oversized tables are separated by text inside one
    heading block, the bridge text may be duplicated into both table boundary
    chunks so each table keeps nearby context. The source table's repeating
    header (lifted at parse time into the ``.tables.json`` sidecar, keyed by the
    slice's ``<table>`` id) is re-injected into the non-first slices *here*, with
    its tokens budgeted out of the per-slice cap *before* splitting — so every
    slice stays ``≤ target_max`` including its header. Because the header now
    lives in the slice from the start, a split table's slices are frozen against
    LevelMerge (re-merging two slices of one table would duplicate the header).
  - AnchorSplit — anchor-driven long-block re-split: short non-table
    paragraphs (≤ 100 chars) are promoted as split points and the block is
    rebalanced toward ``target_ideal``. With no anchor, table-aware fallback
    applies the same row-boundary-first strategy to any oversized table
    paragraph and only character-splits the residual prose (using the
    configured paragraph-semantic overlap).
  - HeadingGlue — body-less heading glue: a section heading with no body of
    its own is glued forward into its first strictly-deeper child block
    (keeping the shallower parent heading), so it is never separated from that
    child nor glued onto an unrelated same-level sibling. A heading with no
    deeper child following is left untouched for LevelMerge.
  - LevelMerge — bottom-up, level-aware small-block merging: undersized blocks
    get absorbed by same-level neighbours (Phase A), then shallower levels
    (Phase B), with a final tail-absorption pass for the trailing remainders.
"""

from __future__ import annotations

import json
import math
import os
import re
from collections.abc import Sequence
from pathlib import Path
from typing import Any, Callable

from lightrag.constants import (
    DEFAULT_P_REFERENCES_HEADINGS,
    DEFAULT_P_REFERENCES_TAIL_N,
)
from lightrag.table_markup import (
    TABLE_TAG_RE as _TABLE_TAG_RE,
    detect_table_format as _detect_table_format,
    extract_table_id as _extract_table_id,
    serialize_html_rows as _serialize_rows_with_wrappers,
    split_html_rows as _split_html_rows,
)
from lightrag.utils import Tokenizer, logger


# ---------------------------------------------------------------------------
# Threshold ratios — derived from the audit-mode constants in
# lightrag/parser/docx/parse_document.py so the trade-off curves
# (table vs. block size, ideal vs. max, etc.) carry over verbatim. The
# absolute values scale with the user-configured ``chunk_token_size``.
# ---------------------------------------------------------------------------

# IDEAL/MAX = 6000/8000 = 0.75 in audit mode.
_IDEAL_RATIO = 0.75

# TABLE_MAX/MAX = 5000/8000 = 0.625 in audit mode.
_TABLE_MAX_RATIO = 0.625

# TABLE_IDEAL/MAX = 3000/8000 = 0.375 in audit mode.
_TABLE_IDEAL_RATIO = 0.375

# TABLE_MIN_LAST/TABLE_MAX = (TABLE_MAX-TABLE_IDEAL)*0.8/TABLE_MAX
#                          = (5000-3000)*0.8/5000 = 0.32 in audit mode.
_TABLE_MIN_LAST_RATIO = 0.32

# SMALL_TAIL_THRESHOLD/MAX = (MAX-IDEAL)/2/MAX = 1000/8000 = 0.125.
_SMALL_TAIL_RATIO = 0.125

# Anchor candidate length is a UI/readability constraint — keep absolute.
_MAX_ANCHOR_CANDIDATE_LENGTH = 100  # characters

# Table tag regex (``_TABLE_TAG_RE``) plus the ``_detect_table_format``,
# ``_split_html_rows`` and ``_serialize_rows_with_wrappers`` helpers are
# imported from :mod:`lightrag.table_markup` so the surrounding-context
# extractor can reuse the same primitives.

_LEGACY_TABLE_CHUNK_SUFFIX_RE = re.compile(r"\s*\[表格片段\d+\]\s*$")
_PART_SUFFIX_RE = re.compile(r"\s*\[part\s+\d+\]\s*$", re.IGNORECASE)

# Markdown heading-line pattern — 1-6 ``#`` followed by one or more spaces.
# Mirrors ``_MD_HEADING_RE`` in ``lightrag/parser/_markdown.py`` (the same
# pattern ``render_heading_line`` uses to render a heading content line) so a
# "heading-only" block — one whose content carries nothing but heading lines —
# can be detected without importing a private symbol across packages.
_HEADING_LINE_RE = re.compile(r"^#{1,6} +")


# ---------------------------------------------------------------------------
# Shared helpers.
# ---------------------------------------------------------------------------


def _count_tokens(tokenizer: Tokenizer, text: str) -> int:
    if not text:
        return 0
    return len(tokenizer.encode(text))


def _bounded_overlap(target_max: int, chunk_overlap_token_size: int) -> int:
    """Return an overlap value safe for recursive-character splitting."""
    overlap = max(int(chunk_overlap_token_size), 0)
    if target_max <= 1:
        return 0
    return min(overlap, target_max - 1)


# Max heading length surfaced in a log line — a heading can be an entire long
# paragraph, so truncate before logging to keep the line readable.
_HEADING_LOG_MAX_CHARS = 50


def _heading_for_log(heading: str | None) -> str:
    """Truncate a heading to the first ``_HEADING_LOG_MAX_CHARS`` chars for logs."""
    text = heading or ""
    if len(text) <= _HEADING_LOG_MAX_CHARS:
        return text
    return text[:_HEADING_LOG_MAX_CHARS] + "…"


def _strip_generated_heading_suffixes(heading: str) -> str:
    """Remove generated split suffixes before assigning a fresh part number."""
    cleaned = (heading or "").rstrip()
    while True:
        next_cleaned = _PART_SUFFIX_RE.sub("", cleaned).rstrip()
        next_cleaned = _LEGACY_TABLE_CHUNK_SUFFIX_RE.sub("", next_cleaned).rstrip()
        if next_cleaned == cleaned:
            return cleaned
        cleaned = next_cleaned


def _append_part_suffix(heading: str, part_number: int) -> str:
    base = _strip_generated_heading_suffixes(heading)
    suffix = f"[part {part_number}]"
    return f"{base} {suffix}" if base else suffix


def _apply_part_suffixes(blocks: list[dict[str, Any]]) -> list[dict[str, Any]]:
    """Tag split fragments from one original block as ``[part n]``."""
    if len(blocks) <= 1:
        return blocks
    for idx, block in enumerate(blocks, start=1):
        block["heading"] = _append_part_suffix(block.get("heading", ""), idx)
    return blocks


def _is_table_paragraph(text: str) -> bool:
    stripped = text.strip()
    return stripped.startswith("<table ") and stripped.endswith("</table>")


def _block_to_paragraphs(content: str) -> list[dict[str, Any]]:
    """Recover the per-paragraph view of a rewritten block.

    The sidecar writer joins paragraphs with ``\\n`` and emits
    tables/equations/drawings as single-line tags with no internal newlines,
    so ``split("\\n")`` faithfully recovers paragraph boundaries.
    """
    paragraphs: list[dict[str, Any]] = []
    for line in content.split("\n"):
        if not line.strip():
            continue
        paragraphs.append({"text": line, "is_table": _is_table_paragraph(line)})
    return paragraphs


def _load_blocks_from_jsonl(blocks_path: str) -> list[dict[str, Any]]:
    """Read ``type == "content"`` rows from a blocks.jsonl file in order."""
    rows: list[dict[str, Any]] = []
    with Path(blocks_path).open("r", encoding="utf-8") as fh:
        for raw in fh:
            raw = raw.strip()
            if not raw:
                continue
            try:
                obj = json.loads(raw)
            except json.JSONDecodeError:
                continue
            if isinstance(obj, dict) and obj.get("type") == "content":
                rows.append(obj)
    return rows


def _references_tail_n() -> int:
    """Trailing-block window for reference detection (live from env).

    Read at chunk time (NOT snapshotted) so changing
    ``CHUNK_P_REFERENCES_TAIL_N`` takes effect on re-runs of already-enqueued
    documents.  Falls back to :data:`DEFAULT_P_REFERENCES_TAIL_N`.
    """
    raw = os.getenv("CHUNK_P_REFERENCES_TAIL_N")
    if raw is None:
        return DEFAULT_P_REFERENCES_TAIL_N
    try:
        return max(int(raw), 0)
    except ValueError:
        logger.warning(
            "[paragraph_semantic] invalid CHUNK_P_REFERENCES_TAIL_N=%r; "
            "using default %d",
            raw,
            DEFAULT_P_REFERENCES_TAIL_N,
        )
        return DEFAULT_P_REFERENCES_TAIL_N


def _references_headings() -> list[str]:
    """Reference heading prefixes (live from env), pipe-separated.

    Read at chunk time (NOT snapshotted), mirroring :func:`_references_tail_n`.
    Falls back to :data:`DEFAULT_P_REFERENCES_HEADINGS`.
    """
    raw = os.getenv("CHUNK_P_REFERENCES_HEADINGS")
    if raw is None:
        return list(DEFAULT_P_REFERENCES_HEADINGS)
    return [seg.strip() for seg in raw.split("|") if seg.strip()]


def _format_dropped_headings(
    headings: Sequence[str], *, max_each: int = 60, max_items: int = 5
) -> str:
    """Render dropped reference headings as a compact, length-bounded string.

    Each heading is truncated to ``max_each`` chars and at most ``max_items``
    are listed (the rest collapse to ``(+N more)``) so the log line stays
    scannable even with a large ``references_tail_n`` or an unusually long
    heading field.
    """
    shown = [
        (h[:max_each] + "…") if len(h) > max_each else h for h in headings[:max_items]
    ]
    rendered = ", ".join(repr(h) for h in shown)
    extra = len(headings) - max_items
    if extra > 0:
        rendered += f" (+{extra} more)"
    return rendered


def _is_reference_heading(heading: str, prefixes: Sequence[str]) -> bool:
    """Whether ``heading`` marks a reference section per ``prefixes``.

    ASCII prefixes (``References``/``Bibliography``) match case-insensitively at
    a word boundary, so ``Referenced work`` does NOT match.  Non-ASCII prefixes
    (the Chinese ``参考文献``) match as a plain prefix — CJK has no word boundary,
    so ``参考文献列表`` still matches.
    """
    low = (heading or "").strip().casefold()
    if not low:
        return False
    for prefix in prefixes:
        pref = (prefix or "").strip()
        if not pref:
            continue
        pl = pref.casefold()
        if not low.startswith(pl):
            continue
        if pref.isascii():
            rest = low[len(pl) :]
            if rest and rest[0].isalnum():
                continue  # e.g. "Referenced" — not a standalone word
        return True
    return False


def _tables_sidecar_path(blocks_path: str) -> str | None:
    """Derive the ``.tables.json`` sidecar path beside a ``.blocks.jsonl`` file.

    Mirrors :func:`lightrag.utils_pipeline.sidecar_modality_path` naming
    (``<base>.blocks.jsonl`` → ``<base>.tables.json``). Returns ``None`` when
    the path does not follow the ``.blocks.jsonl`` convention.
    """
    suffix = ".blocks.jsonl"
    if not blocks_path.endswith(suffix):
        return None
    return blocks_path[: -len(suffix)] + ".tables.json"


def _load_table_headers(blocks_path: str) -> dict[str, str]:
    """Map ``table_id -> repeating-header body`` from the tables.json sidecar.

    Only tables that carry a ``table_header`` field (the cross-page repeating
    header the parser lifted from the source's header rows) are included; the
    value is the verbatim string the writer stored — a JSON 2-D array for JSON
    tables (e.g. ``'[["X", "Y"]]'``) or a raw ``<thead>…</thead>`` fragment for
    HTML tables. A missing / unreadable / malformed sidecar degrades
    silently to ``{}`` — a missing header must never block chunking (same
    tolerance as :func:`_load_blocks_from_jsonl`).
    """
    path = _tables_sidecar_path(blocks_path)
    if not path:
        return {}
    try:
        with Path(path).open("r", encoding="utf-8") as fh:
            data = json.load(fh)
    except (OSError, json.JSONDecodeError):
        return {}
    tables = data.get("tables") if isinstance(data, dict) else None
    if not isinstance(tables, dict):
        return {}
    headers: dict[str, str] = {}
    for table_id, entry in tables.items():
        if not isinstance(entry, dict):
            continue
        header = entry.get("table_header")
        if isinstance(header, str) and header.strip():
            headers[str(table_id)] = header
    return headers


def _parse_header_rows(header_body: str) -> list[Any] | None:
    """Parse a stored ``table_header`` JSON 2-D array, or ``None`` if unusable."""
    try:
        header_rows = json.loads(header_body)
    except (json.JSONDecodeError, TypeError):
        return None
    if not isinstance(header_rows, list) or not header_rows:
        return None
    return header_rows


def _classify_header_format(header_body: str | None) -> str | None:
    """Classify a stored ``table_header`` string as ``"json"`` / ``"html"`` / None.

    The sidecar stores a JSON-format table's header as a JSON 2-D array string
    (e.g. ``'[["H1","H2"]]'``) and an HTML-format table's header as a raw
    ``<thead>…</thead>`` fragment (preserving rowspan/colspan). Returns:

      * ``"json"`` — parses as a non-empty JSON list (see :func:`_parse_header_rows`);
      * ``"html"`` — carries ``<thead`` / ``<tr`` / ``<th`` markup;
      * ``None``   — empty / whitespace / unusable for either path.

    JSON is checked first (authoritative — a JSON 2-D array can never contain
    ``<thead``); the HTML check only sniffs for markup, trusting the writer to
    emit a well-formed ``<thead>``.
    """
    if not header_body or not header_body.strip():
        return None
    if _parse_header_rows(header_body) is not None:
        return "json"
    lowered = header_body.lower()
    if "<thead" in lowered or "<tr" in lowered or "<th" in lowered:
        return "html"
    return None


def _header_reserve_tokens(header_rows: list[Any], tokenizer: Tokenizer) -> int:
    """Tokens to reserve out of a JSON slice's body budget for the header.

    Conservative on purpose: we reserve the cost of the header rows serialized
    on their own (``json.dumps(header_rows)``); the in-place form
    ``header_rows + data`` is actually a couple of tokens cheaper (one shared
    set of brackets), so reserving the standalone cost can only over-budget,
    never under-budget — the safe direction for the cap. (HTML headers reserve
    the raw ``<thead>`` token cost directly in :func:`_split_table_text`.)
    """
    return _count_tokens(tokenizer, json.dumps(header_rows, ensure_ascii=False))


def _inject_header_into_table_slice(text: str, header_body: str) -> str | None:
    """Re-inject the recovered repeating header into a table slice, in place.

    Rebuilds ``<table {attrs}>{header}{original body}</table>`` keeping the
    slice's own ``attrs`` (hence its ``id``):

      * JSON slice + JSON header → header rows are prepended to the row array.
      * HTML slice + HTML header → the raw ``<thead>…</thead>`` ``header_body``
        is spliced verbatim ahead of the body (preserving rowspan/colspan).

    Returns ``None`` (caller leaves the slice untouched) when the slice is not a
    ``<table>`` tag, the header is unusable, the slice format is indeterminate,
    or an HTML slice already carries a ``<thead>``.

    Raises ``ValueError`` on a clear cross-format mismatch (a JSON header into an
    HTML slice, or an HTML header into a JSON slice) — a corrupted / foreign
    sidecar — rather than silently emitting a malformed slice.
    """
    match = _TABLE_TAG_RE.match((text or "").strip())
    if not match:
        return None
    header_fmt = _classify_header_format(header_body)
    if header_fmt is None:
        return None
    attrs = match.group("attrs")
    body = match.group("body")
    slice_fmt = _detect_table_format(attrs, body)

    # CONSISTENCY DEFENSIVE CHECK: a clear json-vs-html disagreement means the
    # sidecar header does not belong to this table (corrupted / foreign).
    if slice_fmt in {"json", "html"} and slice_fmt != header_fmt:
        raise ValueError(
            f"table_header format {header_fmt!r} does not match table slice "
            f"format {slice_fmt!r} for "
            f"{_extract_table_id(attrs) or '<no-id>'}; refusing to inject a "
            "cross-format header (corrupted sidecar?)"
        )

    if slice_fmt == "json":
        # header_fmt is guaranteed "json" here (any mismatch raised above).
        header_rows = _parse_header_rows(header_body)
        if header_rows is None:
            return None
        try:
            rows = json.loads(body)
        except json.JSONDecodeError:
            return None
        if not isinstance(rows, list):
            return None
        # The JSON caller pins the repeating header out of the split body (see
        # ``_split_table_text``), so a slice here only ever holds genuine data
        # rows — prepend the full header verbatim, never heuristically stripping
        # leading rows (which would corrupt a data row that happens to equal a
        # header row).
        merged = json.dumps(header_rows + rows, ensure_ascii=False)
        return f"<table {attrs}>{merged}</table>"
    if slice_fmt == "html":
        # header_fmt is guaranteed "html" here. If the slice already carries a
        # header (a <thead> the splitter kept when it cut inside a multi-row
        # header), prepending another would duplicate it — leave it as-is.
        if "<thead" in body.lower():
            return None
        return f"<table {attrs}>{header_body}{body}</table>"
    # slice_fmt is None (indeterminate): cannot establish consistency, so do
    # not inject and do not raise — leave the slice untouched.
    return None


def _split_html_rows_by_tokens(
    rows: list[tuple[str, str]],
    tokenizer: Tokenizer,
    *,
    target_max: int,
    target_ideal: int,
    last_min: int,
) -> list[list[tuple[str, str]]]:
    """HTML-tuple analog of :func:`_split_rows_by_tokens`.

    Same balanced-split + tail-merge algorithm; tokens are measured on
    the row payloads (``tr_str``) only — wrapper overhead is amortised
    later by the per-chunk serialiser plus the re-split-on-overflow
    safety net in :func:`_split_table_text`.
    """
    total = _count_tokens(tokenizer, "".join(tr for _, tr in rows))
    if total <= target_max or len(rows) <= 1:
        return [rows]

    target_chunks = max(
        math.ceil(total / target_ideal),
        math.ceil(total / target_max),
    )
    target_chunks = min(target_chunks, len(rows))
    target_rows = len(rows) / target_chunks

    chunks: list[list[tuple[str, str]]] = []
    start = 0
    for i in range(target_chunks):
        if i == target_chunks - 1:
            end = len(rows)
        else:
            end = max(start + 1, min(int((i + 1) * target_rows), len(rows)))
            remaining = len(rows) - end
            if remaining > 0 and remaining < target_rows * 0.3:
                end = len(rows)
        chunks.append(rows[start:end])
        start = end
        if start >= len(rows):
            break

    if len(chunks) >= 2:
        last_text = "".join(tr for _, tr in chunks[-1])
        if _count_tokens(tokenizer, last_text) < last_min:
            merged = chunks[-2] + chunks[-1]
            merged_tokens = _count_tokens(tokenizer, "".join(tr for _, tr in merged))
            if merged_tokens <= target_max:
                chunks[-2] = merged
                chunks.pop()
    return chunks


def _dedup_preserving_order(values: list[str]) -> list[str]:
    seen: set[str] = set()
    out: list[str] = []
    for v in values:
        if v and v not in seen:
            seen.add(v)
            out.append(v)
    return out


def _new_block(
    *,
    heading: str,
    parent_headings: list[str],
    level: int,
    paragraphs: list[dict[str, Any]],
    table_chunk_role: str,
    tokenizer: Tokenizer,
    blockids: list[str] | None = None,
) -> dict[str, Any]:
    content = "\n".join(p["text"] for p in paragraphs)
    return {
        "heading": heading,
        "parent_headings": list(parent_headings),
        "level": level,
        "paragraphs": list(paragraphs),
        "content": content,
        "tokens": _count_tokens(tokenizer, content),
        "table_chunk_role": table_chunk_role,
        # Ordered list of source blockids (deduped). Empty when the input
        # blocks.jsonl row did not carry a blockid (raw/legacy input).
        "blockids": _dedup_preserving_order(list(blockids or [])),
    }


# ---------------------------------------------------------------------------
# TableRowSplit — oversized-table re-split with first/middle/last gluing.
# ---------------------------------------------------------------------------


def _split_rows_by_tokens(
    rows: list[Any],
    tokenizer: Tokenizer,
    *,
    target_max: int,
    target_ideal: int,
    last_min: int,
) -> list[list[Any]]:
    """Split ``rows`` into balanced row-bounded chunks (TableRowSplit core)."""
    total = _count_tokens(tokenizer, json.dumps(rows, ensure_ascii=False))
    if total <= target_max or len(rows) <= 1:
        return [rows]

    target_chunks = max(
        math.ceil(total / target_ideal),
        math.ceil(total / target_max),
    )
    # Cap at len(rows) so target_rows >= 1; otherwise int((i+1)*target_rows)
    # can collapse to ``start`` and emit empty <table>[]</table> slices.
    target_chunks = min(target_chunks, len(rows))
    target_rows = len(rows) / target_chunks

    chunks: list[list[Any]] = []
    start = 0
    for i in range(target_chunks):
        if i == target_chunks - 1:
            end = len(rows)
        else:
            # max(start + 1, ...) guarantees forward progress (>= 1 row per
            # slice) even at fractional target_rows boundaries.
            end = max(start + 1, min(int((i + 1) * target_rows), len(rows)))
            remaining = len(rows) - end
            if remaining > 0 and remaining < target_rows * 0.3:
                end = len(rows)
        chunks.append(rows[start:end])
        start = end
        if start >= len(rows):
            break

    # Merge a tiny last chunk back into the previous chunk when feasible.
    if len(chunks) >= 2:
        last_json = json.dumps(chunks[-1], ensure_ascii=False)
        if _count_tokens(tokenizer, last_json) < last_min:
            merged = chunks[-2] + chunks[-1]
            merged_tokens = _count_tokens(
                tokenizer, json.dumps(merged, ensure_ascii=False)
            )
            if merged_tokens <= target_max:
                chunks[-2] = merged
                chunks.pop()
    return chunks


def _character_split_text(
    text: str,
    tokenizer: Tokenizer,
    *,
    target_max: int,
    chunk_overlap_token_size: int = 0,
) -> list[str]:
    """Character-level fallback wrapped to return plain-text pieces.

    Lazy import dodges the ``recursive_character`` ↔ ``paragraph_semantic``
    circular dependency (same pattern as the sidecar-missing fallback in
    :func:`chunking_by_paragraph_semantic`). Callers that split ordinary
    prose pass the paragraph-semantic overlap; table character fallbacks
    leave the default at zero so structured table row chunks do not gain
    implicit row-level overlap.
    """
    from lightrag.chunker.recursive_character import (
        chunking_by_recursive_character,
    )

    pieces = chunking_by_recursive_character(
        tokenizer,
        text,
        target_max,
        chunk_overlap_token_size=_bounded_overlap(target_max, chunk_overlap_token_size),
    )
    return [p["content"] for p in pieces if p.get("content")]


def _split_table_text(
    table_text: str,
    *,
    tokenizer: Tokenizer,
    target_max: int,
    target_ideal: int,
    last_min: int,
    header_body: str | None = None,
) -> list[str]:
    """Split a single oversized ``<table>...</table>`` text into ≤ target_max pieces.

    Strategy (mirrors the user-supplied contract in
    ``docs/ParagraphSemanticChunking-zh.md`` — row boundary first,
    character fallback last):

      1. Match the outer ``<table {attrs}>{body}</table>``. If the regex
         fails, character-split the original text and return.
      2. Detect the body format via :func:`_detect_table_format` (with
         body sniffing when ``attrs`` is silent).
      3. Row-boundary split: JSON via :func:`_split_rows_by_tokens`,
         HTML via :func:`_split_html_rows_by_tokens`. Re-wrap every
         row-chunk as ``<table {attrs}>{rows}</table>``.
      4. A multi-row chunk over the cap is recursively re-split at row
         boundaries. When that collapses to a single row that still
         can't be kept both ``≤ target_max`` and header-complete (the
         row alone exceeds the cap, or it fits but leaves no room for
         the header it would need), the WHOLE table degrades to a
         recursive character split of the original text — its body
         still carries the header, so the header survives as text —
         and a warning is logged. A lone row that needs no header and
         fits the cap is kept whole as legal markup.
      5. Unknown / unparseable format → character-fallback the entire
         original text.

    When ``header_body`` (the stored ``table_header``) is supplied, the source
    table carries a cross-page repeating header. Its representation follows the
    table's format: a JSON 2-D array string for JSON tables, a raw
    ``<thead>…</thead>`` fragment for HTML tables (spans preserved). The header
    is re-injected so every slice carries it, and its token cost is budgeted out
    of the per-slice cap *before* splitting, so each emitted slice stays
    ``≤ target_max`` including the header (HeaderRecovery, §3.3.3):

      * JSON → the header is pinned OUT of the split body and prepended back into
        *every* slice (the first included).
      * HTML → the first slice keeps its own in-body ``<thead>``; the raw
        ``<thead>`` is spliced verbatim only into the *non-first* slices.

    A header whose format clearly disagrees with the table's own format means a
    corrupted / foreign sidecar — :func:`_classify_header_format` flags it and
    this function raises ``ValueError`` rather than splitting with it. A
    header-less table (``header_body is None`` / unusable) leaves the split
    untouched.

    Output strings are either:
      - a re-wrapped ``<table {attrs}>{rows}</table>`` (legal markup,
        callers may keep ``is_table=True`` for these), or
      - a character-fallback fragment (no ``<table>`` wrapper, callers
        should mark ``is_table=False``).
    """
    match = _TABLE_TAG_RE.match((table_text or "").strip())
    if not match:
        return _character_split_text(table_text, tokenizer, target_max=target_max)
    attrs = match.group("attrs")
    body = match.group("body")
    fmt = _detect_table_format(attrs, body)

    # Budget the <table {attrs}></table> wrapper out of the per-chunk
    # caps before calling the row splitter — the splitter only measures
    # the body (json.dumps(rows) / "".join(rows)), so without this the
    # wrapped chunk can exceed target_max purely from the wrapper, which
    # would force a needless character-fallback below.
    wrapper_overhead = _count_tokens(tokenizer, f"<table {attrs}></table>")
    # Classify the stored header once: a "json" header parses to row arrays
    # (pinned out of the JSON body and prepended on injection); an "html" header
    # is a raw <thead> fragment spliced verbatim into non-first HTML slices.
    header_fmt = _classify_header_format(header_body) if header_body else None
    # CONSISTENCY DEFENSIVE CHECK (early): if the table body's own format is
    # concretely known and disagrees with the header's, the sidecar header does
    # not belong to this table — raise before doing any splitting work rather
    # than only discovering it in the per-slice injection loop.
    if header_fmt is not None and fmt in {"json", "html"} and fmt != header_fmt:
        raise ValueError(
            f"table_header format {header_fmt!r} does not match table format "
            f"{fmt!r} for {_extract_table_id(attrs) or '<no-id>'}; refusing to "
            "split with a cross-format header (corrupted sidecar?)"
        )
    # JSON header rows (used by the json_pinned path below); None for HTML / no header.
    header_rows = _parse_header_rows(header_body) if header_fmt == "json" else None
    # HeaderRecovery budget: reserve room for the repeating header that will be
    # prepended into every non-first slice, so a slice + its header never
    # exceeds target_max. The first slice keeps its own header (already in the
    # body), so reserving uniformly only makes it marginally smaller — safe.
    if header_fmt == "json":
        header_overhead = _header_reserve_tokens(header_rows, tokenizer)
    elif header_fmt == "html":
        # The raw <thead> string is spliced verbatim; reserve its own token cost.
        header_overhead = _count_tokens(tokenizer, header_body)
    else:
        header_overhead = 0
    body_budget = max(target_max - wrapper_overhead - header_overhead, 1)
    body_max = body_budget
    body_ideal = max(
        min(target_ideal, target_max) - wrapper_overhead - header_overhead, 1
    )
    body_last_min = max(last_min - wrapper_overhead - header_overhead, 1)
    row_chunks: list[list[Any]] | None = None
    serialize: Callable[[list[Any]], str] | None = None
    # HeaderRecovery (JSON): when the repeating header prefixes the body, pin it
    # OUT of the rows being split so every emitted slice holds only genuine data
    # rows. The full header is then re-injected into every slice afterwards —
    # which means slices never carry header remnants, so injection needs no
    # heuristic dedup (no duplicated header, no dropped data row that happens to
    # equal a header row). Header-less / non-prefixing tables split as before.
    json_pinned = False
    if fmt == "json":
        try:
            rows = json.loads(body)
        except json.JSONDecodeError:
            rows = None
        if isinstance(rows, list) and len(rows) > 1:
            split_rows = rows
            if header_rows is not None and rows[: len(header_rows)] == header_rows:
                data_rows = rows[len(header_rows) :]
                if data_rows:
                    split_rows = data_rows
                    json_pinned = True
            row_chunks = _split_rows_by_tokens(
                split_rows,
                tokenizer,
                target_max=body_max,
                target_ideal=body_ideal,
                last_min=body_last_min,
            )

            def serialize(chunk_rows: list[Any]) -> str:
                return (
                    f"<table {attrs}>"
                    f"{json.dumps(chunk_rows, ensure_ascii=False)}"
                    f"</table>"
                )

    elif fmt == "html":
        rows_html = _split_html_rows(body)
        if rows_html and len(rows_html) > 1:
            row_chunks = _split_html_rows_by_tokens(
                rows_html,
                tokenizer,
                target_max=body_max,
                target_ideal=body_ideal,
                last_min=body_last_min,
            )

            def serialize(chunk_rows: list[tuple[str, str]]) -> str:
                return (
                    f"<table {attrs}>"
                    f"{_serialize_rows_with_wrappers(chunk_rows)}"
                    f"</table>"
                )

    if row_chunks is None or serialize is None:
        # No row boundary available (single-row table, parse failure,
        # unknown format) → character-fallback the whole text.
        return _character_split_text(table_text, tokenizer, target_max=target_max)

    # Re-split any chunk whose wrapped form exceeds the cap before resorting to
    # character-level shredding. For non-first slices the cap is the
    # *header-inclusive* one (``target_max`` minus the header HeaderRecovery
    # will prepend), so a reducible multi-row slice keeps getting cut at row
    # boundaries until each piece can still host the header — rather than being
    # accepted whole and then left header-less.
    #
    # When row-boundary splitting collapses to a single row that still can't
    # satisfy its cap, the table can no longer be kept both ``≤ target_max`` and
    # header-complete via row boundaries. Rather than shred just that slice's
    # body — which for a pinned-header table drops the header entirely, since the
    # header was held out of the body and the later injection pass cannot repair
    # a non-<table> character fragment — the WHOLE table degrades to a recursive
    # character split of the original ``table_text`` (its body still carries the
    # header, so the header content survives as text). This fires for either a
    # row whose content alone exceeds ``target_max`` or a row that fits
    # ``target_max`` but leaves no room for the header it would need; both emit a
    # warning. A lone row that needs no header and fits ``target_max`` is still
    # kept whole as legal markup.
    #
    # Which slices receive an injected header (and so need the header-inclusive
    # cap): for a pinned JSON table the header was held out of the body, so
    # EVERY slice is injected; for HTML the first slice keeps its own in-body
    # <thead> (full target_max) and only later slices are injected.
    effective_max = max(target_max - header_overhead, 1)
    inject_html_nonfirst = fmt == "html" and header_fmt == "html"

    def _degrade_to_recursive() -> list[str]:
        # A single row can no longer be kept both ≤ cap and header-complete via
        # row boundaries → degrade the whole table to a recursive character
        # split of the original text (header included in its body).
        logger.warning(
            "Table %s has a single row that cannot stay within the %d-token cap "
            "alongside its header; degrading the whole table to a recursive "
            "character split (header content preserved as text)",
            _extract_table_id(attrs) or "<no-id>",
            target_max,
        )
        return _character_split_text(table_text, tokenizer, target_max=target_max)

    pieces: list[str] = []
    pending: list[list[Any]] = list(row_chunks)
    while pending:
        chunk_rows = pending.pop(0)
        wrapped = serialize(chunk_rows)
        wrapped_tokens = _count_tokens(tokenizer, wrapped)
        if json_pinned:
            cap = effective_max
        elif inject_html_nonfirst and pieces:
            cap = effective_max
        else:
            cap = target_max
        if wrapped_tokens <= cap:
            pieces.append(wrapped)
            continue
        header_target = json_pinned or (inject_html_nonfirst and bool(pieces))
        if len(chunk_rows) <= 1:
            # A single row can't be split at row boundaries. Keep it whole only
            # when it needs no injected header and still fits the hard cap;
            # otherwise the whole table degrades to a recursive split so the
            # header is never silently dropped.
            if not header_target and wrapped_tokens <= target_max:
                pieces.append(wrapped)
                continue
            return _degrade_to_recursive()
        # Multi-row slice over the (header-inclusive) cap: force a finer cut so
        # each sub-slice stays within budget. Cap the next-pass body budget at
        # half the current wrapped size so target_chunks >= 2 inside the
        # splitter. This guarantees forward progress (one row at minimum per
        # sub-chunk, see the splitter's len(rows) cap).
        halved = max(wrapped_tokens // 2, 1)
        sub_max = max(min(body_max, halved), 1)
        sub_ideal = max(sub_max // 2, 1)
        sub_last_min = max(min(body_last_min, sub_max // 2), 1)
        if fmt == "json":
            sub_chunks = _split_rows_by_tokens(
                chunk_rows,
                tokenizer,
                target_max=sub_max,
                target_ideal=sub_ideal,
                last_min=sub_last_min,
            )
        else:
            sub_chunks = _split_html_rows_by_tokens(
                chunk_rows,
                tokenizer,
                target_max=sub_max,
                target_ideal=sub_ideal,
                last_min=sub_last_min,
            )
        if len(sub_chunks) <= 1:
            # The splitter could not reduce further (e.g. one row already
            # dominates the body). Keep it whole only when it needs no injected
            # header and fits the hard cap; otherwise degrade the whole table to
            # a recursive split (avoids an infinite loop and never drops the
            # header).
            if not header_target and wrapped_tokens <= target_max:
                pieces.append(wrapped)
                continue
            return _degrade_to_recursive()
        # Process the finer cuts before any remaining peer chunks so the
        # output keeps source order.
        pending[0:0] = sub_chunks

    # HeaderRecovery: re-inject the repeating header. For a pinned JSON table
    # the header was held out of the split body, so EVERY slice (the first
    # included) is a pure-data table that gets the header prepended; for HTML
    # the first slice keeps its own in-body <thead> and only later slices are
    # injected. The repair loop kept each injected slice within the
    # header-inclusive cap, so injection fits ≤ target_max — any slice that
    # could not host its header was already routed to the whole-table recursive
    # degrade above (it never reaches here). The ``rebuilt is not None`` /
    # size check below is a defensive belt-and-braces guard.
    # Pinned JSON injects every slice (start=0; even a lone slice must regain
    # the header that was pinned out); HTML injects only later slices (start=1).
    start = 0 if json_pinned else (1 if inject_html_nonfirst else None)
    if start is not None:
        for i in range(start, len(pieces)):
            rebuilt = _inject_header_into_table_slice(pieces[i], header_body)
            if rebuilt is not None and _count_tokens(tokenizer, rebuilt) <= target_max:
                pieces[i] = rebuilt
            else:
                # Defensive belt-and-braces: this slice should carry the
                # recovered header but didn't get it — either it no longer
                # parses as a <table> (``rebuilt is None``) or, despite the
                # pre-split budget, the rebuilt slice would exceed target_max.
                # Both are meant to be unreachable (the repair loop routed any
                # header-incapable slice to the whole-table degrade), so surface
                # it rather than silently emit a header-less slice.
                logger.warning(
                    "Table %s slice %d kept header-less after HeaderRecovery "
                    "(%s); the recovered header could not be injected within the "
                    "%d-token cap",
                    _extract_table_id(attrs) or "<no-id>",
                    i,
                    "no longer parses as <table>" if rebuilt is None else "over-cap",
                    target_max,
                )
    return pieces


def _expand_block_with_table_splits(
    block: dict[str, Any],
    *,
    tokenizer: Tokenizer,
    table_max: int,
    table_ideal: int,
    table_min_last: int,
    target_max: int | None = None,
    chunk_overlap_token_size: int = 0,
    table_headers: dict[str, str] | None = None,
) -> list[dict[str, Any]]:
    """Apply TableRowSplit to one heading-driven block.

    For every embedded table whose tokens exceed ``table_max``:
      - the first row-slice glues with paragraphs already accumulated in
        the current expansion (i.e. content *before* the table);
      - middle slices are emitted as standalone blocks tagged
        ``table_chunk_role == "middle"`` so LevelMerge refuses to merge them;
      - the last slice begins a fresh accumulation that will glue with
        paragraphs *after* the table.

    When a ``last`` table slice is followed by short bridge text and then
    another oversized table's ``first`` slice, the bridge text is split
    into table boundary context: a prefix may be duplicated into the
    previous table block and a suffix into the next table block. If the
    bridge is longer than both context budgets, the remaining middle text
    is emitted as a standalone text block. Tables within the size limit
    pass through untouched.
    """
    if target_max is None:
        target_max = table_max
    target_max = max(int(target_max), 1)
    context_overlap = _bounded_overlap(target_max, chunk_overlap_token_size)
    sep_tokens = _count_tokens(tokenizer, "\n")
    paragraphs = block["paragraphs"]
    has_oversized_table = any(
        p["is_table"] and _count_tokens(tokenizer, p["text"]) > table_max
        for p in paragraphs
    )
    if not has_oversized_table:
        return [block]

    out: list[dict[str, Any]] = []
    cur_paras: list[dict[str, Any]] = []
    # Role to assign to ``cur_paras`` when it next flushes. Tracks the
    # boundary semantics across split-table iterations so the merged
    # block carries "first" / "last" instead of defaulting to "none" —
    # otherwise LevelMerge's directional protections (a "first" block must
    # not absorb backward, a "last" block must not absorb forward) silently
    # disappear after the slice glues with surrounding paragraphs.
    cur_role = "none"

    def flush_cur() -> None:
        nonlocal cur_role
        if not cur_paras:
            cur_role = "none"
            return
        out.append(
            _new_block(
                heading=block["heading"],
                parent_headings=block["parent_headings"],
                level=block["level"],
                paragraphs=cur_paras,
                table_chunk_role=cur_role,
                tokenizer=tokenizer,
                blockids=block.get("blockids"),
            )
        )
        cur_paras.clear()
        cur_role = "none"

    def _append_bridge_block(
        paragraphs: list[dict[str, Any]],
        table_chunk_role: str,
    ) -> None:
        if not paragraphs:
            return
        out.append(
            _new_block(
                heading=block["heading"],
                parent_headings=block["parent_headings"],
                level=block["level"],
                paragraphs=paragraphs,
                table_chunk_role=table_chunk_role,
                tokenizer=tokenizer,
                blockids=block.get("blockids"),
            )
        )

    def _text_paragraph(text: str) -> dict[str, Any] | None:
        if not text or not text.strip():
            return None
        return {"text": text, "is_table": False}

    def _context_capacity(base_paras: list[dict[str, Any]]) -> int:
        if context_overlap <= 0:
            return 0
        base_text = "\n".join(p["text"] for p in base_paras)
        base_tokens = _count_tokens(tokenizer, base_text)
        if base_tokens >= target_max:
            return 0
        # The context paragraph is joined to the table fragment with "\n".
        # Cap each side at target_max // 2 as well so the duplicated bridge
        # text can never dominate the whole block (§3.3.2).
        return max(
            min(
                context_overlap,
                target_max - base_tokens - sep_tokens,
                target_max // 2,
            ),
            0,
        )

    def _flush_last_bridge_before_next_first(
        next_first_para: dict[str, Any],
    ) -> list[dict[str, Any]]:
        """Flush ``last + bridge`` before a following table ``first``.

        Returns context paragraphs to prepend to the following first-table
        block. Only non-table bridge paragraphs are duplicated/sliced; if
        the bridge contains tables we keep the prior non-overlapping flush.
        """
        nonlocal cur_role
        if not cur_paras:
            cur_role = "none"
            return []

        seed_paras = [cur_paras[0]]
        bridge_paras = cur_paras[1:]
        if (
            context_overlap <= 0
            or not bridge_paras
            or any(p.get("is_table", False) for p in bridge_paras)
        ):
            flush_cur()
            return []

        bridge_text = "\n".join(p["text"] for p in bridge_paras)
        bridge_tokens = tokenizer.encode(bridge_text)
        if not bridge_tokens:
            flush_cur()
            return []

        prev_budget = _context_capacity(seed_paras)
        next_budget = _context_capacity([next_first_para])
        bridge_len = len(bridge_tokens)

        if bridge_len <= prev_budget and bridge_len <= next_budget:
            prefix_text = bridge_text
            suffix_text = bridge_text
            middle_text = ""
        else:
            prefix_len = min(prev_budget, bridge_len)
            suffix_len = min(next_budget, bridge_len)
            middle_start = prefix_len
            middle_end = max(middle_start, bridge_len - suffix_len)

            prefix_text = (
                tokenizer.decode(bridge_tokens[:prefix_len]) if prefix_len else ""
            )
            suffix_text = (
                tokenizer.decode(bridge_tokens[bridge_len - suffix_len :])
                if suffix_len
                else ""
            )
            # The standalone middle block keeps R-style overlap with the text
            # that went left (into the previous table block) and right (into the
            # next table block): extend it by ``context_overlap`` tokens on each
            # side, clamped inside ``bridge_tokens``. Because the indices never
            # leave the bridge — which by this branch contains no table
            # paragraphs — the overlap is pure text and never duplicates any
            # ``<table>`` content into the middle block.
            mid_lo = max(0, middle_start - context_overlap)
            mid_hi = min(bridge_len, middle_end + context_overlap)
            middle_text = (
                tokenizer.decode(bridge_tokens[mid_lo:mid_hi])
                if mid_hi > mid_lo and middle_end > middle_start
                else ""
            )

        prev_paras = list(seed_paras)
        prefix_para = _text_paragraph(prefix_text)
        if prefix_para is not None:
            prev_paras.append(prefix_para)
        _append_bridge_block(prev_paras, "last")

        middle_para = _text_paragraph(middle_text)
        if middle_para is not None:
            _append_bridge_block([middle_para], "none")

        cur_paras.clear()
        cur_role = "none"

        suffix_para = _text_paragraph(suffix_text)
        return [suffix_para] if suffix_para is not None else []

    for para in paragraphs:
        text = para["text"]
        if not (para["is_table"] and _count_tokens(tokenizer, text) > table_max):
            cur_paras.append(para)
            continue

        # Trace this oversized table back to its tables.json entry via the
        # slice's <table> id, so the splitter can budget + re-inject the source
        # table's repeating header into the non-first slices (HeaderRecovery).
        header_body: str | None = None
        if table_headers:
            tag_match = _TABLE_TAG_RE.match(text.strip())
            if tag_match:
                table_id = _extract_table_id(tag_match.group("attrs"))
                if table_id:
                    header_body = table_headers.get(table_id)

        # Row-boundary first, character fallback last. ``_split_table_text``
        # returns one or more strings: row-wrapped ``<table>...</table>``
        # fragments where row-splitting succeeded, plain text where it
        # had to character-split (single-row tables, parse failures,
        # rows whose own size exceeded ``table_max``).
        pieces = _split_table_text(
            text,
            tokenizer=tokenizer,
            target_max=table_max,
            target_ideal=table_ideal,
            last_min=table_min_last,
            header_body=header_body,
        )
        if len(pieces) <= 1:
            # No reduction was possible (e.g. very small unparseable table
            # that already fits within ``table_max`` after a no-op character
            # fallback). Keep the original paragraph to preserve content.
            cur_paras.append(para)
            continue

        for chunk_idx, piece_text in enumerate(pieces):
            stripped = piece_text.strip()
            is_still_table = stripped.startswith("<table ") and stripped.endswith(
                "</table>"
            )
            chunk_para = {"text": piece_text, "is_table": is_still_table}
            is_first = chunk_idx == 0
            is_last = chunk_idx == len(pieces) - 1

            if is_first:
                # First slice glues with everything currently accumulated
                # (= the paragraphs that appeared before the table inside
                # this heading block). If the buffer still carries the
                # "last" tail of a previous oversized table, flush it first
                # so its protective role survives instead of being
                # overwritten by "first".
                if cur_role == "last":
                    cur_paras.extend(_flush_last_bridge_before_next_first(chunk_para))
                cur_paras.append(chunk_para)
                cur_role = "first"
            elif is_last:
                # Flush the accumulated "first-glued" block, then begin a
                # new accumulation seeded with this last slice — it will
                # absorb the paragraphs that appear after the table.
                flush_cur()
                cur_paras.append(chunk_para)
                cur_role = "last"
            else:
                # Middle slice: flush the first-glued block, then emit
                # this middle slice as a standalone block that LevelMerge
                # MUST keep intact (table_chunk_role == "middle").
                flush_cur()
                out.append(
                    _new_block(
                        heading=block["heading"],
                        parent_headings=block["parent_headings"],
                        level=block["level"],
                        paragraphs=[chunk_para],
                        table_chunk_role="middle",
                        tokenizer=tokenizer,
                        blockids=block.get("blockids"),
                    )
                )

    flush_cur()
    return out


# ---------------------------------------------------------------------------
# AnchorSplit — anchor-driven long-block re-split.
# ---------------------------------------------------------------------------


def _split_long_block(
    paragraphs: list[dict[str, Any]],
    heading: str,
    parent_headings: list[str],
    level: int,
    table_chunk_role: str,
    *,
    tokenizer: Tokenizer,
    target_max: int,
    target_ideal: int,
    chunk_overlap_token_size: int = 100,
    blockids: list[str] | None = None,
) -> list[dict[str, Any]]:
    """Split an oversized block into balanced sub-blocks at short-paragraph anchors.

    Mirrors :func:`lightrag.parser.docx.parse_document.split_long_block`,
    parameterised on ``target_max`` / ``target_ideal``. Tables (``is_table``)
    are excluded from the anchor candidate pool, so TableRowSplit's row-level
    splits stay intact. When no anchor exists (including the single-
    paragraph oversized case), the no-anchor branch below honors the cap
    via row-boundary splitting (for tables) or character-level splitting
    (for prose). The audit-mode parser would ``sys.exit(1)`` on no-anchor
    failure, but the RAG pipeline must never drop a document silently.
    Character-level splitting of ordinary prose uses
    ``chunk_overlap_token_size`` so long text under one JSONL content row
    keeps semantic continuity across adjacent chunks.
    """
    chunk_overlap_token_size = _bounded_overlap(target_max, chunk_overlap_token_size)
    content = "\n".join(p["text"] for p in paragraphs)
    total = _count_tokens(tokenizer, content)
    if total <= target_max:
        return [
            _new_block(
                heading=heading,
                parent_headings=parent_headings,
                level=level,
                paragraphs=paragraphs,
                table_chunk_role=table_chunk_role,
                tokenizer=tokenizer,
                blockids=blockids,
            )
        ]

    target_blocks = max(
        math.ceil(total / target_ideal),
        math.ceil(total / target_max),
    )
    target_size = total / target_blocks

    # Build anchor candidates with cumulative token offsets. Index 0 is
    # excluded: an anchor at the first paragraph yields an empty leading
    # slice and a tail equal to the input, so it cannot divide the block —
    # selecting it would re-enter this function with the same arguments
    # and recurse until RecursionError.
    candidates: list[dict[str, Any]] = []
    cumulative = 0
    for idx, para in enumerate(paragraphs):
        text = para["text"]
        if (
            idx > 0
            and not para.get("is_table", False)
            and 0 < len(text) <= _MAX_ANCHOR_CANDIDATE_LENGTH
        ):
            candidates.append({"index": idx, "text": text, "position": cumulative})
        cumulative += _count_tokens(tokenizer, text)

    if not candidates:
        # All paragraphs in the block are longer than the anchor-length
        # cap (typical for dense academic prose: every paragraph is a
        # full body section).  Anchor-driven splitting cannot proceed,
        # but we must NOT emit a single oversized chunk: the
        # embedding-time hard fallback uses ``embedding_token_limit``
        # (often 8K), not ``chunk_token_size``, so the chunk would
        # silently exceed the user-configured size.  Prefer
        # row-boundary splitting on any oversized table paragraph
        # before falling back to character-level splitting on residual
        # content — character splitting destroys ``<table>`` markup
        # mid-tag and produces fragments LLMs can't interpret as
        # tables.
        logger.warning(
            "[paragraph_semantic_chunking] block under heading %r exceeds "
            "target_max=%d tokens (~%d tokens) but has no eligible anchor "
            "paragraph (≤ %d chars); preferring table row-boundary split, "
            "falling back to recursive-character splitting on residual "
            "content.",
            _heading_for_log(heading),
            target_max,
            total,
            _MAX_ANCHOR_CANDIDATE_LENGTH,
        )

        # Step 1: expand each oversized table paragraph into row-bounded
        # pieces; non-table or in-budget paragraphs pass through verbatim.
        # ``last_min`` mirrors TableRowSplit's ratio (no separate constant — the
        # tail-merge threshold is purely a row-balancing heuristic).
        last_min = max(int(target_max * _TABLE_MIN_LAST_RATIO), 1)
        pieces: list[str] = []
        for para in paragraphs:
            text = para["text"]
            if (
                para.get("is_table", False)
                and _count_tokens(tokenizer, text) > target_max
            ):
                pieces.extend(
                    _split_table_text(
                        text,
                        tokenizer=tokenizer,
                        target_max=target_max,
                        target_ideal=target_ideal,
                        last_min=last_min,
                    )
                )
            else:
                pieces.append(text)

        # Step 2: greedy-pack pieces into chunks ≤ target_max. A piece
        # that is itself oversized (e.g. a single dense prose paragraph
        # without short anchors) is character-split via
        # :func:`chunking_by_recursive_character` after flushing the
        # current buffer. The "\n" separator inserted by ``"\n".join(buf)``
        # also costs tokens, so it must be debited from the budget —
        # otherwise two pieces that sum to exactly target_max would
        # overflow once joined.
        sep_tokens = _count_tokens(tokenizer, "\n")
        chunks_text: list[str] = []
        buf: list[str] = []
        buf_tokens = 0
        for piece in pieces:
            piece_tokens = _count_tokens(tokenizer, piece)
            if piece_tokens > target_max:
                if buf:
                    chunks_text.append("\n".join(buf))
                    buf, buf_tokens = [], 0
                chunks_text.extend(
                    _character_split_text(
                        piece,
                        tokenizer,
                        target_max=target_max,
                        chunk_overlap_token_size=chunk_overlap_token_size,
                    )
                )
                continue
            addition = piece_tokens + (sep_tokens if buf else 0)
            if buf and buf_tokens + addition > target_max:
                chunks_text.append("\n".join(buf))
                buf, buf_tokens = [], 0
                addition = piece_tokens
            buf.append(piece)
            buf_tokens += addition
        if buf:
            chunks_text.append("\n".join(buf))

        if not chunks_text:
            # Defensive: every piece was empty after stripping. Emit the
            # original oversized block so the document is never silently
            # dropped (matches the prior behaviour of the empty-R branch).
            return [
                _new_block(
                    heading=heading,
                    parent_headings=parent_headings,
                    level=level,
                    paragraphs=paragraphs,
                    table_chunk_role=table_chunk_role,
                    tokenizer=tokenizer,
                    blockids=blockids,
                )
            ]

        sub_blocks: list[dict[str, Any]] = []
        for i, chunk_text in enumerate(chunks_text):
            stripped = chunk_text.strip()
            is_still_table = stripped.startswith("<table ") and stripped.endswith(
                "</table>"
            )
            sub_blocks.append(
                _new_block(
                    heading=heading,
                    parent_headings=parent_headings,
                    level=level,
                    paragraphs=[{"text": chunk_text, "is_table": is_still_table}],
                    # Only the first sub-block keeps the inbound
                    # table_chunk_role; the rest are text-only by
                    # construction (mirrors the anchor-split path below).
                    table_chunk_role=table_chunk_role if i == 0 else "none",
                    tokenizer=tokenizer,
                    blockids=blockids,
                )
            )
        return sub_blocks

    # Pick the anchors closest to evenly-spaced ideal positions.
    pool = list(candidates)
    selected: list[dict[str, Any]] = []
    for i in range(1, target_blocks):
        if not pool:
            break
        ideal_position = i * target_size
        best = min(pool, key=lambda c: abs(c["position"] - ideal_position))
        selected.append(best)
        pool.remove(best)
    selected.sort(key=lambda c: c["index"])

    sub_blocks: list[dict[str, Any]] = []
    prev_idx = 0
    cur_heading = heading
    cur_parents = list(parent_headings)
    # Only the first sub-block keeps the inbound table_chunk_role; the
    # post-anchor sub-blocks are text-only by construction.
    cur_role = table_chunk_role

    for anchor in selected:
        split_idx = anchor["index"]
        slice_paras = paragraphs[prev_idx:split_idx]
        if slice_paras:
            sub_blocks.append(
                _new_block(
                    heading=cur_heading,
                    parent_headings=cur_parents,
                    level=level,
                    paragraphs=slice_paras,
                    table_chunk_role=cur_role,
                    tokenizer=tokenizer,
                    blockids=blockids,
                )
            )
        # Anchor becomes the first paragraph (and heading) of the next sub-block.
        cur_parents = (
            list(parent_headings) + [heading]
            if heading and cur_heading == heading
            else list(cur_parents)
        )
        cur_heading = anchor["text"]
        cur_role = "none"
        prev_idx = split_idx

    tail = paragraphs[prev_idx:]
    if tail:
        sub_blocks.append(
            _new_block(
                heading=cur_heading,
                parent_headings=cur_parents,
                level=level,
                paragraphs=tail,
                table_chunk_role=cur_role,
                tokenizer=tokenizer,
                blockids=blockids,
            )
        )

    # Recursive guard: any sub-block still over target_max is re-split,
    # including single-paragraph subs — the no-anchor branch above honors
    # the cap via row-boundary or character-level splitting and is the
    # only path that can shrink them.
    out: list[dict[str, Any]] = []
    for sub in sub_blocks:
        if sub["tokens"] > target_max:
            out.extend(
                _split_long_block(
                    sub["paragraphs"],
                    sub["heading"],
                    sub["parent_headings"],
                    sub["level"],
                    sub["table_chunk_role"],
                    tokenizer=tokenizer,
                    target_max=target_max,
                    target_ideal=target_ideal,
                    chunk_overlap_token_size=chunk_overlap_token_size,
                    blockids=sub.get("blockids") or blockids,
                )
            )
        else:
            out.append(sub)
    return out


# ---------------------------------------------------------------------------
# LevelMerge — bottom-up, level-aware small-block merging.
# ---------------------------------------------------------------------------


def _can_merge_forward(role: str) -> bool:
    # A split-table slice (first/middle/last) is frozen against LevelMerge: its
    # repeating header is already injected into the slice at TableRowSplit time,
    # so re-merging two slices of one table would duplicate the header mid-body.
    # Only ordinary blocks (role "none") may absorb a following block.
    return role == "none"


def _can_merge_backward(role: str) -> bool:
    # Symmetric to _can_merge_forward — only role "none" may be absorbed by a
    # preceding block. Split-table slices never re-merge (see §3.6 / §3.3.3).
    return role == "none"


def _same_parent_path(a: dict[str, Any], b: dict[str, Any]) -> bool:
    """True when two blocks share the identical parent-heading chain.

    Same-level merging (Phase A, tail absorption) is gated on this so two
    blocks at the same ``level`` are only fused when they are true siblings
    under one parent — blocks that merely happen to share a ``level`` but sit
    under different parents (e.g. ``2.4.1`` vs ``2.5.1``) are NOT merged,
    which is the documented anti-cross-topic-pollution guarantee (§3.6.1 #4).
    Blocks with no parents (preamble / non-hierarchical input) compare equal.
    """
    return list(a.get("parent_headings") or []) == list(b.get("parent_headings") or [])


def _is_descendant(shallow: dict[str, Any], deep: dict[str, Any]) -> bool:
    """True when ``deep`` is nested under ``shallow`` in the heading tree.

    Cross-level absorption (Phase B, shallower-absorbs-deeper) is gated on
    this: the shallow block's full heading path (its ``parent_headings`` plus
    its own ``heading``) must be a prefix of the deep block's
    ``parent_headings``. This prevents a shallow block from swallowing a deeper
    block that belongs to an unrelated branch of the document tree. The
    shallow heading is stripped of any generated ``[part n]`` suffix first
    because ``parent_headings`` never carry that suffix. A shallow block with
    no heading (preamble) yields an empty path that prefixes anything, so such
    blocks are not blocked from absorbing — they have no hierarchy to violate.
    """
    head = _strip_generated_heading_suffixes(shallow.get("heading") or "")
    shallow_full = list(shallow.get("parent_headings") or []) + ([head] if head else [])
    deep_parents = list(deep.get("parent_headings") or [])
    return deep_parents[: len(shallow_full)] == shallow_full


def _merged_pair(
    left: dict[str, Any],
    right: dict[str, Any],
    *,
    keep: str,
    tokenizer: Tokenizer,
) -> dict[str, Any]:
    base = left if keep == "left" else right
    paragraphs = list(left["paragraphs"]) + list(right["paragraphs"])
    content = left["content"] + "\n\n" + right["content"]
    merged_blockids = _dedup_preserving_order(
        list(left.get("blockids") or []) + list(right.get("blockids") or [])
    )
    return {
        "heading": base["heading"],
        "parent_headings": list(base["parent_headings"]),
        "level": base["level"],
        "paragraphs": paragraphs,
        "content": content,
        "tokens": _count_tokens(tokenizer, content),
        "table_chunk_role": "none",
        "blockids": merged_blockids,
    }


def _is_heading_only(block: dict[str, Any]) -> bool:
    """Return True when ``block`` carries a heading but no body content.

    A blocks.jsonl content row always renders its heading as the first line
    (``render_heading_line`` → ``"#" * level + " " + text``) and appends body
    paragraphs after it, so a heading-only section's ``content`` consists
    solely of heading lines. This still holds after two heading-only blocks
    are glued together, letting :func:`_glue_heading_only_blocks` cascade
    down a chain of bare ancestor headings.

    The ``heading`` guard excludes preamble blocks (text before any heading)
    and AnchorSplit anchor sub-blocks, whose body paragraphs are prose rather
    than heading lines.

    Note: a body line that genuinely begins with ``#`` + space is the same
    accepted ambiguity documented in ``lightrag/parser/_markdown.py`` and is
    treated as a heading line here too — rare prose, tolerated.
    """
    if not block.get("heading"):
        return False
    saw_line = False
    for line in (block.get("content") or "").split("\n"):
        stripped = line.strip()
        if not stripped:
            continue
        saw_line = True
        if not _HEADING_LINE_RE.match(stripped):
            return False
    return saw_line


def _glue_heading_only_blocks(
    blocks: list[dict[str, Any]],
    *,
    tokenizer: Tokenizer,
    target_max: int,
    target_ideal: int,
    chunk_overlap_token_size: int = 0,
) -> list[dict[str, Any]]:
    """Glue each body-less heading block forward into its deeper child block.

    A section heading with no body of its own (e.g. ``## 2.4`` immediately
    followed by ``### 2.4.1``) must travel WITH its first child rather than be
    left as a lone trailing heading or glued onto an unrelated same-level
    sibling (e.g. ``## 2.3``). Running this before LevelMerge bonds the bare
    heading to its child first. See ``docs/ParagraphSemanticChunking-zh.md``
    §3.5 for the full rationale.

    For a block that :func:`_is_heading_only` recognises whose NEXT block is
    STRICTLY DEEPER and whose ``table_chunk_role`` is ``none`` or ``first``,
    the pair merges with ``keep="left"`` — preserving the shallower parent's
    identity (``heading`` / ``level`` / ``parent_headings``) while appending the
    child's content. The merge is re-evaluated in place, so a chain of bare
    ancestors (``# 2`` → ``## 2.4`` → ``### 2.4.1``) collapses into one block
    keeping the shallowest identity. (Only ``none`` / ``first`` can follow a
    heading-only row — ``middle`` / ``last`` occur only inside one row's table.
    Gluing into a ``first`` slice KEEPS the ``first`` role so LevelMerge still
    cannot absorb it backward, protecting the table boundary.)

    Gluing is FORWARD only: a body-less heading whose next block is not deeper
    (a shallower/sibling heading, or end of list) is left for LevelMerge, not
    pulled backward into a deeper previous block (which would invert the
    hierarchy and demote the heading's level).

    Prepending the heading line(s) can tip the bonded block past ``target_max``,
    and nothing downstream re-splits an oversized chunk, so :func:`_split_to_cap`
    re-splits it here while keeping the heading attached to real body; every
    emitted piece honours ``target_max``. Because ``keep="left"`` preserves the
    parent's ``level``, the bonded group remains an ordinary small block (NOT
    pinned independent) that LevelMerge may still merge backward by its usual
    peer/tail rules — this pre-pass only guarantees the heading is never
    separated FROM its child.
    """
    if len(blocks) <= 1:
        return blocks

    out: list[dict[str, Any]] = []

    def _split_full(paras: list[dict[str, Any]], block: dict[str, Any], **kw):
        args = {
            "target_max": target_max,
            "target_ideal": target_ideal,
            "chunk_overlap_token_size": chunk_overlap_token_size,
            "blockids": block.get("blockids"),
        }
        args.update(kw)
        return _split_long_block(
            paras,
            block["heading"],
            block["parent_headings"],
            block["level"],
            block.get("table_chunk_role", "none"),
            tokenizer=tokenizer,
            **args,
        )

    def _split_to_cap(block: dict[str, Any]) -> list[dict[str, Any]]:
        # The bonded block exceeds target_max. Peel the leading run of
        # heading-line paragraphs (the prepended parent heading plus the child's
        # own heading line, or a chain) off the body, split only the BODY, then
        # glue the heading prefix back onto the FIRST body piece. The prefix is
        # never handed to the splitter, so the bare heading can never be sliced
        # off as a content-less first chunk — a heading-only orphan that LevelMerge
        # would re-absorb backward into the previous sibling. (Fusing the prefix
        # into the body instead does NOT work: when the fused paragraph itself
        # exceeds the cap, char-splitting re-separates the headings at their
        # newline and the orphan returns.)
        paras = block["paragraphs"]
        n = 0
        for para in paras:
            if para.get("is_table", False) or not _HEADING_LINE_RE.match(
                para["text"].strip()
            ):
                break
            n += 1
        prefix, body = paras[:n], paras[n:]
        prefix_tokens = _count_tokens(tokenizer, "\n".join(p["text"] for p in prefix))
        sep_tokens = _count_tokens(tokenizer, "\n")

        if not prefix or not body or prefix_tokens + sep_tokens >= target_max:
            # No prefix to protect; no body to anchor on; OR the prefix alone
            # fills/exceeds the cap (a very long title, or a tiny
            # chunk_token_size) so it cannot be kept whole. The hard cap wins
            # over heading-intactness: split the whole block directly —
            # _split_long_block char-splits any oversized heading line so every
            # emitted piece still honours target_max.
            return _split_full(paras, block)

        # Split the body at the FULL target_max so later body pieces (which do
        # NOT carry the prefix) keep the full budget — never shrunk to the
        # leftover first-chunk budget. Reserve room for the prefix only on the
        # first piece, re-splitting that one piece if it cannot also hold it.
        pieces = _split_full(body, block)
        first, rest = pieces[0], list(pieces[1:])
        if prefix_tokens + sep_tokens + first["tokens"] > target_max:
            reduced_max = max(target_max - prefix_tokens - sep_tokens, 1)
            refit = _split_full(
                first["paragraphs"],
                block,
                target_max=reduced_max,
                target_ideal=min(target_ideal, reduced_max),
                blockids=first.get("blockids") or block.get("blockids"),
            )
            first, rest = refit[0], list(refit[1:]) + rest
        rebuilt = _new_block(
            heading=block["heading"],
            parent_headings=block["parent_headings"],
            level=block["level"],
            paragraphs=prefix + first["paragraphs"],
            table_chunk_role=block.get("table_chunk_role", "none"),
            tokenizer=tokenizer,
            blockids=first.get("blockids") or block.get("blockids"),
        )
        return [rebuilt, *rest]

    def _emit(block: dict[str, Any], *, glued: bool) -> None:
        # A forward-glued block can be tipped over target_max by its prepended
        # heading line(s); re-split via AnchorSplit so the hard cap still holds.
        if glued and block["tokens"] > target_max:
            out.extend(_split_to_cap(block))
        else:
            out.append(block)

    cur = blocks[0]
    cur_glued = False
    for nxt in blocks[1:]:
        nxt_role = nxt.get("table_chunk_role", "none")
        if (
            _is_heading_only(cur)
            and nxt.get("level", 1) > cur.get("level", 1)
            and nxt_role in ("none", "first")
        ):
            cur = _merged_pair(cur, nxt, keep="left", tokenizer=tokenizer)
            # Preserve a "first" table-slice role so the bonded block still
            # cannot be absorbed backward into the previous sibling by LevelMerge
            # (the prepended heading is exactly the preceding context a "first"
            # slice is meant to carry). "none" stays "none" — unchanged.
            cur["table_chunk_role"] = nxt_role
            cur_glued = True
        else:
            _emit(cur, glued=cur_glued)
            cur, cur_glued = nxt, False
    _emit(cur, glued=cur_glued)
    return out


def _merge_small_blocks(
    blocks: list[dict[str, Any]],
    *,
    tokenizer: Tokenizer,
    target_max: int,
    target_ideal: int,
    small_tail_threshold: int,
) -> list[dict[str, Any]]:
    """Bottom-up, level-aware small-block merging.

    Re-implementation of
    :func:`lightrag.parser.docx.parse_document.merge_small_blocks`,
    parameterised on the chunk-size targets and operating on internal
    block dicts (no ``uuid`` / ``table_header`` propagation needed: the
    chunking output schema does not carry them).
    """
    if len(blocks) <= 1:
        return blocks

    result = list(blocks)
    levels = sorted({b.get("level", 1) for b in result}, reverse=True)

    for current_level in levels:
        # Phase A — same-level merging.
        changed = True
        while changed:
            changed = False
            new_result: list[dict[str, Any]] = []
            i = 0
            while i < len(result):
                cur = result[i]
                cur_tokens = cur["tokens"]
                cur_level = cur.get("level", 1)
                cur_role = cur.get("table_chunk_role", "none")
                below_ideal = 0 < cur_tokens < target_ideal
                is_cur_lv = cur_level == current_level

                if below_ideal and is_cur_lv:
                    merged = False

                    if _can_merge_forward(cur_role) and i + 1 < len(result):
                        nxt = result[i + 1]
                        if (
                            nxt.get("level", 1) == current_level
                            and _can_merge_backward(nxt.get("table_chunk_role", "none"))
                            and _same_parent_path(cur, nxt)
                        ):
                            combined = _merged_pair(
                                cur, nxt, keep="left", tokenizer=tokenizer
                            )
                            if combined["tokens"] <= target_max:
                                new_result.append(combined)
                                i += 2
                                changed = True
                                merged = True

                    if not merged and _can_merge_backward(cur_role) and new_result:
                        prev = new_result[-1]
                        if (
                            prev.get("level", 1) == current_level
                            and _can_merge_forward(prev.get("table_chunk_role", "none"))
                            and prev["tokens"] < target_ideal
                            and _same_parent_path(prev, cur)
                        ):
                            combined = _merged_pair(
                                prev, cur, keep="left", tokenizer=tokenizer
                            )
                            if combined["tokens"] <= target_max:
                                new_result[-1] = combined
                                i += 1
                                changed = True
                                merged = True

                    if not merged:
                        new_result.append(cur)
                        i += 1
                else:
                    # Tail absorption: an at-or-above-IDEAL block can absorb
                    # a short run of subsequent same-level blocks if their
                    # combined size stays under SMALL_TAIL_THRESHOLD and
                    # fits within target_max — eliminates the document's
                    # trailing sliver of zero-content remainders.
                    if is_cur_lv and cur_tokens >= target_ideal and cur_role == "none":
                        tail_total = 0
                        end_idx = i + 1
                        for j in range(i + 1, len(result)):
                            nxt = result[j]
                            if nxt.get("level", 1) != current_level:
                                break
                            # A split-table slice (first/middle/last) is frozen —
                            # never pull one into a tail-absorption run, which
                            # would re-merge it and duplicate its header.
                            if nxt.get("table_chunk_role", "none") != "none":
                                break
                            # Same-level only is not enough — a sibling under a
                            # different parent would be cross-topic. Stop the run
                            # at the first block whose parent path diverges.
                            if not _same_parent_path(cur, nxt):
                                break
                            tail_total += nxt["tokens"]
                            end_idx = j + 1
                        if (
                            tail_total > 0
                            and tail_total < small_tail_threshold
                            and cur_tokens + tail_total <= target_max
                        ):
                            absorbed_paragraphs = list(cur["paragraphs"])
                            absorbed_content = cur["content"]
                            for j in range(i + 1, end_idx):
                                nxt = result[j]
                                absorbed_paragraphs.extend(nxt["paragraphs"])
                                absorbed_content += "\n\n" + nxt["content"]
                            # The cheap predicate above sums per-block
                            # tokens, but absorption joins blocks with
                            # ``"\n\n"`` — those separator tokens are
                            # real and can push the merged block over
                            # target_max. Re-measure the joined content
                            # before committing to absorb.
                            absorbed_tokens = _count_tokens(tokenizer, absorbed_content)
                            if absorbed_tokens <= target_max:
                                new_result.append(
                                    {
                                        "heading": cur["heading"],
                                        "parent_headings": list(cur["parent_headings"]),
                                        "level": cur["level"],
                                        "paragraphs": absorbed_paragraphs,
                                        "content": absorbed_content,
                                        "tokens": absorbed_tokens,
                                        "table_chunk_role": "none",
                                    }
                                )
                                i = end_idx
                                changed = True
                                continue
                    new_result.append(cur)
                    i += 1
            result = new_result

        # Phase B — cross-level absorption (shallower absorbs deeper).
        changed = True
        while changed:
            changed = False
            new_result = []
            i = 0
            while i < len(result):
                cur = result[i]
                cur_tokens = cur["tokens"]
                cur_level = cur.get("level", 1)
                cur_role = cur.get("table_chunk_role", "none")
                below_ideal = 0 < cur_tokens < target_ideal
                is_cur_lv = cur_level == current_level

                if below_ideal and is_cur_lv:
                    merged = False

                    if _can_merge_forward(cur_role) and i + 1 < len(result):
                        nxt = result[i + 1]
                        if (
                            nxt.get("level", 1) > current_level
                            and _can_merge_backward(nxt.get("table_chunk_role", "none"))
                            and _is_descendant(cur, nxt)
                        ):
                            combined = _merged_pair(
                                cur, nxt, keep="left", tokenizer=tokenizer
                            )
                            if combined["tokens"] <= target_max:
                                new_result.append(combined)
                                i += 2
                                changed = True
                                merged = True

                    if not merged and _can_merge_backward(cur_role) and new_result:
                        prev = new_result[-1]
                        if (
                            prev.get("level", 1) < current_level
                            and _can_merge_forward(prev.get("table_chunk_role", "none"))
                            and prev["tokens"] < target_ideal
                            and _is_descendant(prev, cur)
                        ):
                            combined = _merged_pair(
                                prev, cur, keep="left", tokenizer=tokenizer
                            )
                            if combined["tokens"] <= target_max:
                                new_result[-1] = combined
                                i += 1
                                changed = True
                                merged = True

                    if not merged:
                        new_result.append(cur)
                        i += 1
                else:
                    new_result.append(cur)
                    i += 1
            result = new_result

    return result


# ---------------------------------------------------------------------------
# Public entrypoint.
# ---------------------------------------------------------------------------


def chunking_by_paragraph_semantic(
    tokenizer: Tokenizer,
    content: str,
    chunk_token_size: int = 2000,
    *,
    blocks_path: str | None = None,
    chunk_overlap_token_size: int = 100,
    drop_references: bool = False,
    references_tail_n: int | None = None,
    references_headings: Sequence[str] | None = None,
    doc_id: str | None = None,
) -> list[dict[str, Any]]:
    """Paragraph Semantic Chunking — the ``chunking="P"`` strategy.

    Reads structured blocks from a ``.blocks.jsonl`` sidecar (HeadingBlocks
    output, emitted by any sidecar-producing parser — native / mineru /
    docling) and applies TableRowSplit (table re-split + glue), AnchorSplit
    (anchor-driven long-block re-split) and LevelMerge (bottom-up, level-aware
    merging). Output rows match the schema produced by
    :func:`lightrag.chunker.chunking_by_token_size`
    (``tokens``/``content``/``chunk_order_index``), enriched with a nested
    ``heading`` block (``{level, heading, parent_headings}``) and an optional
    ``sidecar`` tracing source blockids, so KG extraction can leverage the
    document hierarchy.

    Signature follows the LightRAG chunker contract — the standard
    prefix ``(tokenizer, content, chunk_token_size)`` is shared with
    every other chunker, while strategy-specific knobs are keyword-only:

      - ``blocks_path`` (this strategy's required input — the
        ``.blocks.jsonl`` sidecar produced at parse time)

    Knobs that ``chunking_by_token_size`` exposes for delimiter-based
    splitting (``split_by_character``, ``split_by_character_only``) are
    deliberately absent here because paragraph-semantic chunks are
    heading-aligned. ``chunk_overlap_token_size`` is supported for two
    paragraph-semantic cases where overlap preserves meaning inside one
    JSONL content row: recursive-character fallback for long prose, and
    bridge text duplicated around adjacent oversized table boundary chunks.
    When one original ``blocks.jsonl`` content row is split into multiple
    fragments, every fragment heading receives a row-local ``[part n]``
    suffix; unsplit rows keep their original heading.

    Args:
        tokenizer: LightRAG tokenizer (used for all token counting; matches
            the unit used by ``chunk_token_size``).
        content: Merged plain-text content of the document. Used as the
            fallback corpus when ``blocks_path`` is missing or unreadable
            so the pipeline never silently drops a document.
        chunk_token_size: Hard upper bound for each chunk in tokens. The
            ideal target is set at 75 % of this value (mirroring the
            audit-mode 6000/8000 ratio); see threshold ratio constants
            above for the full mapping.
        blocks_path: Path to the document's ``.blocks.jsonl`` sidecar
            (typically ``parsed_data["blocks_path"]``). When ``None``,
            unreadable, or empty, this function falls back to
            :func:`chunking_by_recursive_character` on ``content``
            (per ``docs/FileProcessingConfiguration-zh.md`` §3).
            That fallback hard-requires ``langchain-text-splitters``;
            an :class:`ImportError` is surfaced rather than silently
            degrading further.
        chunk_overlap_token_size: Token overlap used only when P must
            fall back to recursive-character splitting of ordinary text,
            and as the per-side budget for duplicating text between two
            adjacent oversized table chunks. Structural table row splits
            remain row-bounded and non-overlapping.
        drop_references: When ``True``, drop the trailing reference section
            (e.g. "References" / "参考文献") before splitting/merging. A block
            is removed only when it BOTH sits within the last
            ``references_tail_n`` content blocks AND its heading matches a
            reference prefix. This is the only reference knob that is
            snapshotted into ``chunk_options`` (it may come from a per-file
            hint) and recorded in ``doc_status.metadata['chunk_opts']``.
        references_tail_n: Trailing-block window for the detection above.
            ``None`` (the normal pipeline value) means "read
            ``CHUNK_P_REFERENCES_TAIL_N`` env live at run time, default
            ``DEFAULT_P_REFERENCES_TAIL_N``"; a kwarg is only passed by tests.
        references_headings: Reference heading prefixes. ``None`` means "read
            ``CHUNK_P_REFERENCES_HEADINGS`` env live at run time, default
            :data:`DEFAULT_P_REFERENCES_HEADINGS`"; a kwarg is only passed by
            tests. Neither knob is snapshotted, so editing the env changes the
            behaviour of re-runs without re-enqueueing.
        doc_id: Document id, used only to attribute the drop_references log
            line to a document; ``None`` renders as ``"unknown"``.

    Returns:
        Ordered list of chunk dicts, each shaped::

            {
                "tokens": int,
                "content": str,
                "chunk_order_index": int,
                "heading": {"level": int, "heading": str,
                            "parent_headings": list[str]},
                "sidecar": {"type": "block", "id": str, "refs": [...]},  # optional
            }

        ``heading`` is a nested block (``level`` / ``parent_headings`` live
        inside it, not at the top level; the ``[part n]`` suffix lands on
        ``heading["heading"]``). ``sidecar`` is present only when the source
        row carried a ``blockid``.

    Notes:
        blocks.jsonl field analysis vs. algorithm requirements:

          - ``content`` (``\\n``-joined, tags single-line) → split back into
            per-paragraph text via ``split("\\n")``; lossless because
            table/equation/drawing tags are single-line replacements.
          - ``heading`` / ``parent_headings`` / ``level`` → consumed by
            AnchorSplit/LevelMerge for hierarchy-aware merging. A row split into
            multiple fragments gets a ``[part n]`` suffix on ``heading`` after
            TableRowSplit/AnchorSplit and before LevelMerge; ``parent_headings``
            are untouched.
          - ``<table … format="json">{rows_json}</table>`` → JSON body
            row-level re-split in TableRowSplit when over the per-table cap;
            short text between two split tables may be repeated into both
            boundary chunks, with any longer middle remainder left standalone.
          - ``<equation>`` / ``<drawing>`` → atomic non-table paragraphs,
            neither splittable nor anchorable.
          - Per-paragraph paraIds are NOT preserved (only block-level
            ``positions[].range``); fine, the output schema does not need them.
          - ``table_slice`` is always ``"none"`` in blocks.jsonl (tables kept
            whole at parse time), so the ``table_chunk_role`` LevelMerge
            consumes is recomputed on the fly inside TableRowSplit.
    """
    target_max = max(int(chunk_token_size), 1)
    target_ideal = max(int(target_max * _IDEAL_RATIO), 1)
    table_max = max(int(target_max * _TABLE_MAX_RATIO), 1)
    table_ideal = max(int(target_max * _TABLE_IDEAL_RATIO), 1)
    table_min_last = max(int(table_max * _TABLE_MIN_LAST_RATIO), 1)
    small_tail_threshold = max(int(target_max * _SMALL_TAIL_RATIO), 1)
    overlap = _bounded_overlap(target_max, chunk_overlap_token_size)

    rows: list[dict[str, Any]] = []
    fallback_reason: str | None = None
    if not blocks_path:
        fallback_reason = "blocks_path is empty"
    else:
        try:
            rows = _load_blocks_from_jsonl(blocks_path)
        except OSError as exc:
            fallback_reason = f"cannot read blocks.jsonl at {blocks_path}: {exc}"
        else:
            if not rows:
                fallback_reason = (
                    f"blocks.jsonl at {blocks_path} contains no content rows"
                )

    if fallback_reason is not None:
        # Defer to recursive-character chunking when the sidecar is
        # absent — ensures non-structured documents and edge-case parses
        # still produce chunks instead of silently dropping content.  The
        # document contract (FileProcessingConfiguration-zh.md §3) is
        # explicit that P falls back to R; that contract requires
        # langchain-text-splitters to be installed, so an ImportError
        # here is intentional rather than a silent degrade to F.  Lazy
        # import dodges the recursive_character ↔ paragraph_semantic
        # circular dependency.
        logger.warning(
            "[paragraph_semantic_chunking] %s; falling back to "
            "recursive-character chunking with chunk_token_size=%d.",
            fallback_reason,
            target_max,
        )
        from lightrag.chunker.recursive_character import (
            chunking_by_recursive_character,
        )

        return chunking_by_recursive_character(
            tokenizer,
            content,
            target_max,
            chunk_overlap_token_size=overlap,
        )

    # Drop the trailing reference section before any split/merge runs (operates
    # on the raw blocks.jsonl content rows — one heading == one block).  A block
    # is dropped only when it BOTH sits within the last ``tail_n`` rows AND its
    # heading matches a reference prefix; the tail window is a safety guard so a
    # mid-document "References" subsection is never removed.  Detection knobs are
    # read live from env (NOT snapshotted) unless injected via kwargs (tests).
    if drop_references and rows:
        prefixes = (
            list(references_headings)
            if references_headings is not None
            else _references_headings()
        )
        tail_n = (
            max(int(references_tail_n), 0)
            if references_tail_n is not None
            else _references_tail_n()
        )
        if tail_n:
            start = max(0, len(rows) - tail_n)
            kept: list[dict[str, Any]] = []
            dropped_headings: list[str] = []
            for idx, row in enumerate(rows):
                if idx >= start and _is_reference_heading(
                    row.get("heading", "") or "", prefixes
                ):
                    dropped_headings.append((row.get("heading") or "").strip())
                else:
                    kept.append(row)
            # Protect against an empty document: base the guard on rows that
            # still carry content (the loop below skips blank-content rows), so
            # a pathological sidecar whose only kept rows are empty does not
            # silently produce zero chunks.
            if dropped_headings and any(
                (row.get("content") or "").strip() for row in kept
            ):
                logger.info(
                    "[paragraph_semantic] removed %d reference block(s) %s "
                    "from doc_id: %s",
                    len(dropped_headings),
                    _format_dropped_headings(dropped_headings),
                    doc_id or "unknown",
                )
                rows = kept
            elif dropped_headings:
                logger.warning(
                    "[paragraph_semantic] dropping reference block(s) %s would "
                    "leave no content; keeping them from doc_id: %s",
                    _format_dropped_headings(dropped_headings),
                    doc_id or "unknown",
                )

    # Build initial blocks (HeadingBlocks output, already persisted).
    initial: list[dict[str, Any]] = []
    for row in rows:
        text = row.get("content", "") or ""
        if not text.strip():
            continue
        paragraphs = _block_to_paragraphs(text)
        if not paragraphs:
            continue
        row_blockid = str(row.get("blockid") or "").strip()
        initial.append(
            _new_block(
                heading=row.get("heading", "") or "",
                parent_headings=list(row.get("parent_headings") or []),
                level=int(row.get("level", 1) or 1),
                paragraphs=paragraphs,
                table_chunk_role="none",
                tokenizer=tokenizer,
                blockids=[row_blockid] if row_blockid else None,
            )
        )

    # Repeating-header lookup for table slices that lose their header during
    # row-splitting. Loaded once here (never on the missing-sidecar fallback
    # path, which returned above); an absent / unreadable tables.json degrades
    # to an empty map, so HeaderRecovery silently no-ops. Fed into TableRowSplit
    # so the header's tokens are budgeted *before* splitting (the slice + header
    # never exceeds target_max), rather than backfilled after the cap is set.
    table_headers = _load_table_headers(blocks_path) if blocks_path else {}

    # TableRowSplit/AnchorSplit are run per original blocks.jsonl content row so split
    # fragments can be labelled with [part n] using a row-local counter
    # before LevelMerge merges small neighbours.
    after_c: list[dict[str, Any]] = []
    for blk in initial:
        block_after_b = _expand_block_with_table_splits(
            blk,
            tokenizer=tokenizer,
            table_max=table_max,
            table_ideal=table_ideal,
            table_min_last=table_min_last,
            target_max=target_max,
            chunk_overlap_token_size=overlap,
            table_headers=table_headers,
        )

        block_after_c: list[dict[str, Any]] = []
        for split_blk in block_after_b:
            block_after_c.extend(
                _split_long_block(
                    split_blk["paragraphs"],
                    split_blk["heading"],
                    split_blk["parent_headings"],
                    split_blk["level"],
                    split_blk.get("table_chunk_role", "none"),
                    tokenizer=tokenizer,
                    target_max=target_max,
                    target_ideal=target_ideal,
                    chunk_overlap_token_size=overlap,
                    blockids=split_blk.get("blockids") or blk.get("blockids"),
                )
            )
        after_c.extend(_apply_part_suffixes(block_after_c))

    # HeadingGlue — glue each body-less heading block FORWARD into its
    # strictly-deeper child (role "none" or the "first" slice of a split table),
    # so the bare heading never reaches _merge_small_blocks detached from its
    # child content nor glued onto an unrelated same-level sibling. Gluing into a
    # "first" slice keeps the "first" role so LevelMerge still can't pull it back.
    # A body-less heading whose next block is not deeper is left for LevelMerge
    # (not pulled into a deeper previous block — that would invert the
    # hierarchy). A forward-glued block tipped past target_max is re-split via
    # AnchorSplit so the hard cap holds. Runs across original rows after [part n]
    # tagging is finalised (heading-only rows are never split, so no part suffix).
    after_c = _glue_heading_only_blocks(
        after_c,
        tokenizer=tokenizer,
        target_max=target_max,
        target_ideal=target_ideal,
        chunk_overlap_token_size=overlap,
    )

    # LevelMerge — bottom-up, level-aware small-block merging.
    final = _merge_small_blocks(
        after_c,
        tokenizer=tokenizer,
        target_max=target_max,
        target_ideal=target_ideal,
        small_tail_threshold=small_tail_threshold,
    )

    # Convert internal block dicts to the new chunk schema: nested heading
    # dict + sidecar block carrying source blockid refs so the multimodal
    # pipeline (and document-delete cache cleanup) can trace each chunk
    # back to its blocks.jsonl row(s).
    chunks: list[dict[str, Any]] = []
    for idx, blk in enumerate(final):
        body = blk["content"].strip()
        if not body:
            continue
        chunk_dict: dict[str, Any] = {
            "tokens": blk["tokens"],
            "content": body,
            "chunk_order_index": idx,
            "heading": {
                "level": int(blk.get("level") or 0),
                "heading": str(blk.get("heading") or ""),
                "parent_headings": list(blk.get("parent_headings") or []),
            },
        }
        blockids = blk.get("blockids") or []
        if blockids:
            chunk_dict["sidecar"] = {
                "type": "block",
                "id": blockids[0],
                "refs": [{"type": "block", "id": bid} for bid in blockids],
            }
        chunks.append(chunk_dict)
    return chunks
