"""Semantic vector chunking — the ``"V"`` strategy.

Wraps LangChain's :class:`SemanticChunker` (from ``langchain-experimental``)
which splits text by sentence embeddings: it first segments the input into
sentences, embeds each sentence (in adjacent windows of ``buffer_size``),
and finds breakpoints where the cosine distance between consecutive
windows crosses a threshold derived from the chosen distribution
(``percentile`` / ``standard_deviation`` / ``interquartile`` /
``gradient``).

The chunker exposed here is ``async`` because LightRAG's
:class:`EmbeddingFunc` is async.  Internally we call SemanticChunker
synchronously inside :func:`asyncio.to_thread` and bridge the embedding
calls back to the main event loop via
:func:`asyncio.run_coroutine_threadsafe`.

Caveats:
  - SemanticChunker does NOT enforce a maximum chunk size; the caller's
    ``chunk_token_size`` is *advisory* here.  Oversized chunks will be
    hard-split before embedding by
    :func:`lightrag.utils.enforce_chunk_token_limit_before_embedding`.
  - When ``embedding_func`` is ``None`` we log a warning and fall back to
    :func:`lightrag.chunker.chunking_by_recursive_character` — V's only
    differentiator is embeddings, and R is the closest structural-only
    alternative.
"""

from __future__ import annotations

import asyncio
import re
from typing import TYPE_CHECKING, Any

from lightrag.constants import DEFAULT_SENTENCE_SPLIT_REGEX
from lightrag.utils import EmbeddingFunc, Tokenizer, logger

if TYPE_CHECKING:
    from langchain_experimental.text_splitter import (
        SemanticChunker as SemanticChunkerType,
    )
else:
    SemanticChunkerType = Any

try:
    from langchain_core.embeddings import Embeddings
    from langchain_experimental.text_splitter import SemanticChunker

    _LANGCHAIN_EXPERIMENTAL_AVAILABLE = True
except ImportError:
    _LANGCHAIN_EXPERIMENTAL_AVAILABLE = False
    Embeddings = object  # type: ignore[assignment,misc]
    SemanticChunker = None  # type: ignore[assignment]


class _AsyncEmbeddingFuncAdapter(Embeddings):
    """Bridge a LightRAG :class:`EmbeddingFunc` (async) to LangChain's
    sync :class:`Embeddings` interface used by ``SemanticChunker``.

    The adapter must be constructed inside the running event loop so it
    can capture the loop reference; the blocking ``embed_documents`` /
    ``embed_query`` calls are then made from a worker thread (via
    :func:`asyncio.to_thread` in the public chunker) and bounce back to
    the captured loop with :func:`asyncio.run_coroutine_threadsafe`.
    """

    def __init__(
        self,
        embedding_func: EmbeddingFunc,
        loop: asyncio.AbstractEventLoop,
    ) -> None:
        self._embedding_func = embedding_func
        self._loop = loop

    def _run(self, texts: list[str], context: str) -> list[list[float]]:
        future = asyncio.run_coroutine_threadsafe(
            self._embedding_func(texts, context=context),
            self._loop,
        )
        result = future.result()
        return [list(map(float, vec)) for vec in result]

    def embed_documents(self, texts: list[str]) -> list[list[float]]:
        return self._run(list(texts), context="document")

    def embed_query(self, text: str) -> list[float]:
        return self._run([text], context="query")[0]


def _sentence_spans(text: str, sentences: list[str]) -> list[tuple[int, int]]:
    spans: list[tuple[int, int]] = []
    cursor = 0
    for sentence in sentences:
        if not sentence:
            spans.append((cursor, cursor))
            continue
        start = text.find(sentence, cursor)
        if start < 0:
            start = text.find(sentence)
        if start < 0:
            start = cursor
        end = start + len(sentence)
        spans.append((start, end))
        cursor = end
    return spans


def _trim_span(text: str, start: int, end: int) -> tuple[int, int]:
    start = max(0, min(start, len(text)))
    end = max(start, min(end, len(text)))
    while start < end and text[start].isspace():
        start += 1
    while end > start and text[end - 1].isspace():
        end -= 1
    return start, end


def _semantic_groups_with_spans(
    splitter: SemanticChunkerType,
    text: str,
) -> list[tuple[str, int, int]]:
    """Mirror SemanticChunker grouping while keeping original source spans.

    .. warning::
        This re-implements the body of ``SemanticChunker.split_text`` so each group
        carries its exact source span (``text[start:end]``) instead of the upstream
        ``" ".join(sentences)`` reflow. It relies on **private** members
        (``sentence_split_regex``, ``breakpoint_threshold_type``, ``min_chunk_size``,
        ``number_of_chunks``, ``_calculate_sentence_distances``,
        ``_threshold_from_clusters``, ``_calculate_breakpoint_threshold``). Verified
        byte-for-byte against ``langchain-experimental`` 0.3.2–0.4.x (the range pinned
        in ``pyproject.toml``: ``langchain-experimental>=0.3.2,<1``). If that pin is
        widened, re-verify against the new upstream ``split_text`` —
        ``tests/chunker/test_chunker_semantic_vector.py`` has a drift guard that
        compares this mirror's grouping to the live ``splitter.split_text`` output.
    """
    single_sentences_list = re.split(splitter.sentence_split_regex, text)
    spans = _sentence_spans(text, single_sentences_list)

    def _group(start_index: int, end_index: int) -> tuple[str, int, int] | None:
        start, _ = spans[start_index]
        _, end = spans[end_index]
        start, end = _trim_span(text, start, end)
        if start >= end:
            return None
        return text[start:end], start, end

    if len(single_sentences_list) == 1:
        group = _group(0, 0)
        return [group] if group else []
    if (
        splitter.breakpoint_threshold_type == "gradient"
        and len(single_sentences_list) == 2
    ):
        return [g for i in range(2) if (g := _group(i, i)) is not None]

    distances, sentences = splitter._calculate_sentence_distances(single_sentences_list)
    if splitter.number_of_chunks is not None:
        breakpoint_distance_threshold = splitter._threshold_from_clusters(distances)
        breakpoint_array = distances
    else:
        breakpoint_distance_threshold, breakpoint_array = (
            splitter._calculate_breakpoint_threshold(distances)
        )

    indices_above_thresh = [
        i for i, x in enumerate(breakpoint_array) if x > breakpoint_distance_threshold
    ]

    chunks: list[tuple[str, int, int]] = []
    start_index = 0
    for index in indices_above_thresh:
        end_index = index
        group_sentences = sentences[start_index : end_index + 1]
        combined_text = " ".join([d["sentence"] for d in group_sentences])
        if (
            splitter.min_chunk_size is not None
            and len(combined_text) < splitter.min_chunk_size
        ):
            continue
        group = _group(start_index, end_index)
        if group is not None:
            chunks.append(group)
        start_index = index + 1

    if start_index < len(sentences):
        group = _group(start_index, len(sentences) - 1)
        if group is not None:
            chunks.append(group)
    return chunks


async def chunking_by_semantic_vector(
    tokenizer: Tokenizer,
    content: str,
    chunk_token_size: int = 1200,
    *,
    embedding_func: EmbeddingFunc | None = None,
    breakpoint_threshold_type: str = "percentile",
    breakpoint_threshold_amount: float | None = None,
    buffer_size: int = 1,
    sentence_split_regex: str = DEFAULT_SENTENCE_SPLIT_REGEX,
    number_of_chunks: int | None = None,
    min_chunk_size: int | None = None,
) -> list[dict[str, Any]]:
    """Semantic vector chunker — the ``"V"`` chunking strategy.

    Args:
        tokenizer: LightRAG tokenizer (used for output token counts).
        content: Text to split.
        chunk_token_size: Hard upper bound (tokens). SemanticChunker does
            NOT enforce a maximum natively, so any piece that exceeds
            this value is re-split via
            :func:`chunking_by_recursive_character` before being emitted.
        embedding_func: LightRAG :class:`EmbeddingFunc`. When ``None``
            this chunker logs a warning and falls back to
            :func:`chunking_by_recursive_character`.
        breakpoint_threshold_type: ``percentile`` | ``standard_deviation``
            | ``interquartile`` | ``gradient`` (LangChain default:
            ``percentile``).
        breakpoint_threshold_amount: Threshold magnitude. ``None`` lets
            LangChain pick the per-type default (e.g. 95 for percentile).
        buffer_size: Number of adjacent sentences combined when computing
            distances (LangChain default: 1).
        sentence_split_regex: Pattern fed to LangChain's
            :class:`SemanticChunker` for the initial sentence split.
            Default extends the upstream English-only pattern with
            Chinese sentence terminators ``。？！`` so mixed-language and
            pure-Chinese inputs split correctly.
        number_of_chunks: Optional target chunk count (LangChain SemanticChunker).
        min_chunk_size: Optional minimum character size for semantic groups.

    Returns:
        Ordered list of ``{"tokens", "content", "chunk_order_index"}``
        dicts.
    """
    if not content or not content.strip():
        return []

    if embedding_func is None:
        # V's only differentiator is embeddings — without them the
        # closest neighbour is R's structural splitting.  V chunks are
        # non-overlapping by design (semantic boundaries), so the
        # fallback uses ``chunk_overlap_token_size=0`` to preserve that
        # semantic and avoid LangChain's "overlap > chunk_size" guard
        # for very small ``chunk_token_size``.
        logger.warning(
            "[semantic_vector] embedding_func is None; falling back to "
            "recursive-character chunking."
        )
        from lightrag.chunker.recursive_character import (
            chunking_by_recursive_character,
        )

        return chunking_by_recursive_character(
            tokenizer,
            content,
            chunk_token_size,
            chunk_overlap_token_size=0,
        )

    if not _LANGCHAIN_EXPERIMENTAL_AVAILABLE:
        raise ImportError(
            "langchain-experimental is required for the 'V' chunking "
            "strategy; install with `pip install langchain-experimental>=0.3.2`."
        )

    loop = asyncio.get_running_loop()
    adapter = _AsyncEmbeddingFuncAdapter(embedding_func, loop)

    chunker_kwargs: dict[str, Any] = {
        "embeddings": adapter,
        "buffer_size": int(buffer_size),
        "breakpoint_threshold_type": breakpoint_threshold_type,
        "sentence_split_regex": sentence_split_regex,
        "number_of_chunks": number_of_chunks,
        "min_chunk_size": min_chunk_size,
    }
    if breakpoint_threshold_amount is not None:
        chunker_kwargs["breakpoint_threshold_amount"] = float(
            breakpoint_threshold_amount
        )

    splitter = SemanticChunker(**chunker_kwargs)
    pieces = await asyncio.to_thread(_semantic_groups_with_spans, splitter, content)

    # SemanticChunker has no internal size cap; oversized pieces here
    # would otherwise rely on the embedding-time hard fallback (which
    # uses ``embedding_token_limit``, not ``chunk_token_size``) to split
    # them.  Enforce ``chunk_token_size`` directly via R for any piece
    # that exceeds it so the user-configured size is actually honored.
    # Lazy import dodges the recursive_character ↔ semantic_vector
    # circular dependency (same pattern as the embedding-None fallback
    # above).
    from lightrag.chunker.recursive_character import (
        chunking_by_recursive_character,
    )

    target_max = max(int(chunk_token_size), 1)
    results: list[dict[str, Any]] = []
    for piece, source_start, source_end in pieces:
        body = piece.strip()
        if not body:
            continue
        piece_tokens = len(tokenizer.encode(body))
        if piece_tokens <= target_max:
            results.append(
                {
                    "tokens": piece_tokens,
                    "content": body,
                    "chunk_order_index": len(results),
                    "_source_span": {
                        "start": source_start,
                        "end": source_end,
                    },
                }
            )
            continue
        # Oversized semantic piece: re-split via R while preserving the
        # surrounding chunk order.  ``chunk_overlap_token_size=0`` keeps
        # V's non-overlapping semantics.
        sub_pieces = chunking_by_recursive_character(
            tokenizer,
            body,
            target_max,
            chunk_overlap_token_size=0,
        )
        for sub in sub_pieces:
            sub_body = sub.get("content", "")
            if not sub_body:
                continue
            sub_span = sub.get("_source_span")
            source_span = None
            if isinstance(sub_span, dict):
                try:
                    source_span = {
                        "start": source_start + int(sub_span["start"]),
                        "end": source_start + int(sub_span["end"]),
                    }
                except (KeyError, TypeError, ValueError):
                    source_span = None
            results.append(
                {
                    "tokens": sub.get("tokens", len(tokenizer.encode(sub_body))),
                    "content": sub_body,
                    "chunk_order_index": len(results),
                    **({"_source_span": source_span} if source_span else {}),
                }
            )
    return results
