"""Tests for text chunking behavior.

These tests verify chunking invariants regardless of the underlying
implementation.
"""

import tempfile
from pathlib import Path

import pytest

from lilbee.data.chunk import chunk_text


class TestChunkText:
    def test_empty_input(self):
        assert chunk_text("") == []
        assert chunk_text("   ") == []

    def test_short_text_single_chunk(self):
        chunks = chunk_text("This is a short paragraph.")
        assert len(chunks) >= 1
        assert "short paragraph" in chunks[0]

    def test_long_text_produces_multiple_chunks(self):
        """Multi-topic input forces topic breaks so semantic can't merge into one chunk."""
        topics = [
            "Solar panels convert sunlight into electricity via photovoltaic cells.",
            "The FDA approved a new clinical trial for a diabetes treatment.",
            "Quantum computers use qubits and entanglement for parallel computation.",
            "Ancient Roman aqueducts used gravity to transport water across cities.",
        ]
        paragraphs = [topics[i % len(topics)] + f" Variant {i}." for i in range(60)]
        text = "\n\n".join(paragraphs)
        chunks = chunk_text(text)
        assert len(chunks) > 1

    def test_multiple_paragraphs_all_present(self):
        paragraphs = [f"Unique paragraph {i} with specific content." for i in range(20)]
        text = "\n\n".join(paragraphs)
        chunks = chunk_text(text)
        joined = " ".join(chunks)
        for p in paragraphs:
            assert p in joined, f"Missing: {p}"

    def test_long_sentence_splits(self):
        sentence = "word " * 500
        chunks = chunk_text(sentence)
        assert len(chunks) >= 1

    def test_plain_text_no_heading_context(self):
        text = "Just plain text without any markdown headings."
        chunks = chunk_text(text)
        assert len(chunks) >= 1
        assert "plain text" in chunks[0]

    def test_semantic_disabled_uses_char_budget(self, monkeypatch):
        """When cfg.semantic_chunking is False, chunker falls back to fixed char budget."""
        from lilbee.core.config import cfg

        monkeypatch.setattr(cfg, "semantic_chunking", False)
        chunks = chunk_text("Plain text chunked without the semantic branch.")
        assert chunks
        assert "Plain text" in " ".join(chunks)

    def test_use_semantic_false_bypasses_semantic(self, monkeypatch):
        """Caller can opt out of semantic chunking even when cfg has it enabled."""
        from lilbee.core.config import cfg
        from lilbee.data.chunk import build_chunking_config

        monkeypatch.setattr(cfg, "semantic_chunking", True)
        bypassed = build_chunking_config(use_semantic=False)
        assert bypassed.chunker_type == "text"
        enabled = build_chunking_config()
        assert enabled.chunker_type == "semantic"


class TestBuildChunkingConfig:
    def test_semantic_enabled_uses_semantic_chunker_with_embedding(self, monkeypatch):
        """Semantic path requires an EmbeddingConfig or kreuzberg silently falls back."""
        from lilbee.core.config import cfg
        from lilbee.data.chunk import build_chunking_config

        monkeypatch.setattr(cfg, "semantic_chunking", True)
        monkeypatch.setattr(cfg, "topic_threshold", 0.6)
        result = build_chunking_config()
        assert result.chunker_type == "semantic"
        assert result.topic_threshold == pytest.approx(0.6, abs=1e-5)
        assert result.embedding is not None

    def test_semantic_respects_max_chars_when_embedding_present(self, monkeypatch):
        """With an embedding attached kreuzberg honors max_chars on the semantic path."""
        from lilbee.core.config import cfg
        from lilbee.data.chunk import CHARS_PER_TOKEN, build_chunking_config

        monkeypatch.setattr(cfg, "semantic_chunking", True)
        monkeypatch.setattr(cfg, "chunk_size", 512)
        result = build_chunking_config()
        assert result.max_chars == 512 * CHARS_PER_TOKEN

    def test_char_budget_when_disabled(self, monkeypatch):
        from lilbee.core.config import cfg
        from lilbee.data.chunk import CHARS_PER_TOKEN, build_chunking_config

        monkeypatch.setattr(cfg, "semantic_chunking", False)
        monkeypatch.setattr(cfg, "chunk_size", 512)
        monkeypatch.setattr(cfg, "chunk_overlap", 100)
        result = build_chunking_config()
        assert result.chunker_type == "text"
        assert result.max_chars == 512 * CHARS_PER_TOKEN
        assert result.max_overlap == 100 * CHARS_PER_TOKEN
        assert result.embedding is None

    def test_disabled_does_not_attach_embedding(self, monkeypatch):
        """When semantic is off, no EmbeddingConfig is built; avoids the ONNX download."""
        from lilbee.core.config import cfg
        from lilbee.data.chunk import build_chunking_config

        monkeypatch.setattr(cfg, "semantic_chunking", False)
        result = build_chunking_config()
        assert result.embedding is None


class TestMarkdownChunking:
    def test_splits_on_headings(self):
        md = (
            "# Intro\n\nHello world paragraph with enough text.\n\n"
            "## Details\n\nSome details here with more content."
        )
        chunks = chunk_text(md, mime_type="text/markdown", heading_context=True)
        assert len(chunks) >= 1

    def test_heading_hierarchy_prepended(self):
        md = "# Top\n\nTop content here with text.\n\n## Sub\n\nContent under sub section."
        chunks = chunk_text(md, mime_type="text/markdown", heading_context=True)
        assert any("Top" in c and "Sub" in c for c in chunks)

    def test_nested_headings(self):
        md = (
            "# A\n\nA body text here.\n\n"
            "## B\n\nB body text here.\n\n"
            "### C\n\nC body text here.\n\n"
            "## D\n\nD body text here."
        )
        chunks = chunk_text(md, mime_type="text/markdown", heading_context=True)
        assert len(chunks) >= 1
        joined = " ".join(chunks)
        assert "A" in joined
        assert "D" in joined

    def test_content_before_first_heading(self):
        md = "Preamble text content.\n\n# First Section\n\nSection body content."
        chunks = chunk_text(md, mime_type="text/markdown", heading_context=True)
        assert len(chunks) >= 1
        joined = " ".join(chunks)
        assert "Preamble" in joined
        assert "Section body" in joined

    def test_empty_markdown(self):
        assert chunk_text("", mime_type="text/markdown", heading_context=True) == []


@pytest.mark.xdist_group("tree_sitter")
class TestCodeChunker:
    """Tree-sitter code chunker tests: grouped to avoid fork-unsafe C parser collisions."""

    def test_python_function_extraction(self):
        from lilbee.data.code_chunker import chunk_code

        code = '''
def hello():
    """Say hello."""
    print("hello")

def goodbye(name: str) -> str:
    """Say goodbye."""
    return f"goodbye {name}"

class Greeter:
    def greet(self):
        return "hi"
'''
        with tempfile.NamedTemporaryFile(suffix=".py", mode="w", delete=False) as f:
            f.write(code)
            f.flush()
            path = Path(f.name)

        try:
            chunks = chunk_code(path)
            assert len(chunks) >= 1
            joined = "\n".join(c.chunk for c in chunks)
            assert "hello" in joined
        finally:
            path.unlink()

    def test_unsupported_extension_returns_fallback(self):
        from lilbee.data.code_chunker import chunk_code

        with tempfile.NamedTemporaryFile(suffix=".xyz_unsupported", mode="w", delete=False) as f:
            f.write("some content here")
            f.flush()
            path = Path(f.name)

        try:
            chunks = chunk_code(path)
            assert isinstance(chunks, list)
        finally:
            path.unlink()

    def test_is_code_file_common_extensions(self):
        from lilbee.data.code_chunker import is_code_file

        assert is_code_file(Path("main.py"))
        assert is_code_file(Path("app.js"))
        assert is_code_file(Path("lib.rs"))
        assert is_code_file(Path("server.go"))

    def test_is_code_file_non_code(self):
        from lilbee.data.code_chunker import is_code_file

        assert not is_code_file(Path("photo.png"))
        assert not is_code_file(Path("document.pdf"))

    def test_detect_language_python(self):
        from lilbee.data.code_chunker import _detect_language

        result = _detect_language(Path("main.py"))
        assert result is not None
        assert "python" in result.lower()

    def test_ensure_language_exception_returns_false(self):
        from unittest.mock import patch

        from lilbee.data.code_chunker import _ensure_language

        with patch("lilbee.data.code_chunker.has_language", side_effect=RuntimeError("boom")):
            assert _ensure_language("python") is False

    def test_find_line_no_match_returns_start(self):
        from lilbee.data.code_chunker import find_line

        lines = ["aaa", "bbb", "ccc"]
        assert find_line("zzz", lines, 0) == 1

    def test_ensure_language_false_triggers_fallback(self):
        from unittest.mock import patch

        from lilbee.data.code_chunker import chunk_code

        with tempfile.NamedTemporaryFile(suffix=".py", mode="w", delete=False) as f:
            f.write("x = 1\n" * 20)
            f.flush()
            path = Path(f.name)

        try:
            with patch("lilbee.data.code_chunker._ensure_language", return_value=False):
                chunks = chunk_code(path)
                assert isinstance(chunks, list)
        finally:
            path.unlink()

    def test_process_exception_triggers_fallback(self):
        from unittest.mock import patch

        from lilbee.data.code_chunker import chunk_code

        with tempfile.NamedTemporaryFile(suffix=".py", mode="w", delete=False) as f:
            f.write("x = 1\n" * 20)
            f.flush()
            path = Path(f.name)

        # Force _ensure_language True so the try/except in chunk_code is
        # actually entered; otherwise CI hosts without tree-sitter Python
        # preloaded short-circuit to the no-language fallback first.
        try:
            with (
                patch("lilbee.data.code_chunker._ensure_language", return_value=True),
                patch("lilbee.data.code_chunker.process", side_effect=RuntimeError("parse fail")),
            ):
                chunks = chunk_code(path)
                assert isinstance(chunks, list)
        finally:
            path.unlink()

    def test_empty_symbols_triggers_fallback(self):
        from unittest.mock import patch

        from lilbee.data.code_chunker import chunk_code

        with tempfile.NamedTemporaryFile(suffix=".py", mode="w", delete=False) as f:
            f.write("x = 1\n" * 20)
            f.flush()
            path = Path(f.name)

        try:
            # Stub out _ensure_language and process so we land on the
            # no-symbols branch deterministically, regardless of whether
            # tree-sitter Python is installed on the host.
            with (
                patch("lilbee.data.code_chunker._ensure_language", return_value=True),
                patch("lilbee.data.code_chunker.process", return_value={}),
                patch("lilbee.data.code_chunker._extract_symbols", return_value=[]),
            ):
                chunks = chunk_code(path)
                assert isinstance(chunks, list)
        finally:
            path.unlink()

    def test_ensure_language_returns_true_when_already_loaded(self):
        """The early-return branch fires when has_language already says yes."""
        from unittest.mock import patch

        from lilbee.data.code_chunker import _ensure_language

        with patch("lilbee.data.code_chunker.has_language", return_value=True) as has:
            assert _ensure_language("python") is True
            has.assert_called_once_with("python")

    def test_ensure_language_runs_install_when_not_loaded(self):
        """Cover the init()-then-recheck branch deterministically; without this,
        coverage of that line drifts whenever the tree-sitter language pack
        ships pre-installed for a given Python version."""
        from unittest.mock import patch

        from lilbee.data.code_chunker import PackConfig, _ensure_language

        with (
            patch("lilbee.data.code_chunker.has_language", side_effect=[False, True]) as has,
            patch("lilbee.data.code_chunker.init") as init_mock,
        ):
            assert _ensure_language("python") is True
            assert has.call_count == 2
            init_mock.assert_called_once_with(PackConfig(languages=["python"]))

    def test_chunk_code_empty_source_returns_empty(self):
        """Empty (whitespace-only) source short-circuits before tree-sitter."""
        from lilbee.data.code_chunker import chunk_code

        with tempfile.NamedTemporaryFile(suffix=".py", mode="w", delete=False) as f:
            f.write("\n   \n")
            f.flush()
            path = Path(f.name)

        try:
            assert chunk_code(path) == []
        finally:
            path.unlink()

    def test_chunk_code_emits_symbol_chunks(self):
        """Cover the structured-chunk emission path when _extract_symbols
        returns at least one symbol. Mocked so the test is independent of
        whether tree-sitter actually parses on this CI host."""
        from unittest.mock import patch

        from lilbee.data.code_chunker import SymbolInfo, chunk_code

        symbol = SymbolInfo(
            name="hello",
            kind="function",
            line_start=1,
            line_end=3,
            text="def hello():\n    return 1\n",
        )

        with tempfile.NamedTemporaryFile(suffix=".py", mode="w", delete=False) as f:
            f.write("def hello():\n    return 1\n")
            f.flush()
            path = Path(f.name)

        try:
            with (
                patch("lilbee.data.code_chunker._ensure_language", return_value=True),
                patch("lilbee.data.code_chunker.process", return_value={}),
                patch("lilbee.data.code_chunker._extract_symbols", return_value=[symbol]),
            ):
                chunks = chunk_code(path)
        finally:
            path.unlink()

        assert len(chunks) == 1
        first = chunks[0]
        assert "function: hello" in first.chunk
        assert first.line_start == 1
        assert first.line_end == 3
        assert first.chunk_index == 0


class TestHeadingContextNoDuplicate:
    def test_heading_context_no_duplicate(self):
        """kreuzberg >= 4.8.5 should not duplicate headings with prepend_heading_context."""
        md = "# Title\n\n" + "Word " * 500 + "\n\n## Section\n\n" + "More " * 500
        chunks = chunk_text(md, mime_type="text/markdown", heading_context=True)
        for c in chunks:
            parts = c.split("\n\n", 2)
            if len(parts) >= 2:
                ctx_last = parts[0].rsplit(" > ", 1)[-1].strip()
                assert parts[1].strip() != ctx_last, f"Duplicate heading in chunk: {c[:100]}"


class TestChunkTextEmptyResult:
    def test_returns_empty_when_no_chunks(self):
        from unittest.mock import MagicMock, patch

        from lilbee.data.chunk import chunk_text

        mock_result = MagicMock()
        mock_result.chunks = []
        with patch("kreuzberg.extract_bytes_sync", return_value=mock_result):
            assert chunk_text("some text") == []
