"""Integration tests for ``parse_docling`` with the unified sidecar pipeline.

Stubs :class:`DoclingRawClient.download_into` so no real docling-serve is
contacted; the focus is on:

- happy path: cache miss → fake bundle written → sidecar emitted with all
  expected files at the spec-compliant locations
- cache hit: a pre-existing valid ``*.docling_raw/`` + manifest causes
  ``DoclingRawClient.download_into`` NOT to be called
- ``LIGHTRAG_FORCE_REPARSE_DOCLING=true`` forces a re-download even when
  the manifest is valid
- source content swap → cache miss
- options_signature change (``DOCLING_OCR_LANG`` toggle) → cache miss
- adapter sees zero blocks → parse fails loudly (no half-baked sidecar)
"""

from __future__ import annotations

import asyncio
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

import numpy as np
import pytest

from lightrag import LightRAG
from lightrag.constants import FULL_DOCS_FORMAT_LIGHTRAG
from lightrag.parser.external import (
    Manifest,
    ManifestFile,
    compute_size_and_hash,
    write_manifest,
)
from lightrag.parser.external.docling.cache import (
    compute_options_signature,
    snapshot_tunable_env,
)
from lightrag.parser.external.docling.client import FIXED_CONSTANTS
from lightrag.parser.base import ParseContext
from lightrag.parser.registry import get_parser
from lightrag.utils import EmbeddingFunc, Tokenizer


async def _parse_via_registry(rag, engine, doc_id, file_path, content_data):
    """Drive a parser the way the pipeline worker does (registry dispatch)."""
    result = await get_parser(engine).parse(
        ParseContext(rag, doc_id, file_path, content_data)
    )
    return result.to_dict()


class _SimpleTokenizerImpl:
    def encode(self, content: str) -> list[int]:
        return [ord(ch) for ch in content]

    def decode(self, tokens: list[int]) -> str:
        return "".join(chr(t) for t in tokens)


async def _mock_embedding(texts: list[str]) -> np.ndarray:
    return np.random.rand(len(texts), 32)


async def _mock_llm(prompt: Any, **kwargs: Any) -> str:
    return '{"name":"x","summary":"s","detail_description":"d"}'


def _new_rag(tmp_path: Path) -> LightRAG:
    return LightRAG(
        working_dir=str(tmp_path),
        workspace=f"test-docling-sidecar-{tmp_path.name}",
        llm_model_func=_mock_llm,
        embedding_func=EmbeddingFunc(
            embedding_dim=32,
            max_token_size=4096,
            func=_mock_embedding,
        ),
        tokenizer=Tokenizer("mock-tokenizer", _SimpleTokenizerImpl()),
        vlm_process_enable=False,
    )


_FAKE_DOCLING_JSON = {
    "schema_name": "DoclingDocument",
    "version": "1.10.0",
    "origin": {"filename": "demo.pdf", "mimetype": "application/pdf"},
    "body": {
        "self_ref": "#/body",
        "children": [
            {"$ref": "#/texts/0"},
        ],
        "content_layer": "body",
        "label": "unspecified",
    },
    "groups": [],
    "texts": [
        {
            "self_ref": "#/texts/0",
            "label": "section_header",
            "text": "Intro",
            "orig": "Intro",
            "level": 1,
            "content_layer": "body",
            "children": [
                {"$ref": "#/texts/1"},
                {"$ref": "#/tables/0"},
                {"$ref": "#/pictures/0"},
                {"$ref": "#/texts/2"},
            ],
            "prov": [
                {
                    "page_no": 1,
                    "bbox": {
                        "l": 10.0,
                        "t": 100.0,
                        "r": 200.0,
                        "b": 80.0,
                        "coord_origin": "BOTTOMLEFT",
                    },
                    "charspan": [0, 5],
                }
            ],
        },
        {
            "self_ref": "#/texts/1",
            "label": "text",
            "text": "Body paragraph.",
            "orig": "Body paragraph.",
            "content_layer": "body",
            "prov": [
                {
                    "page_no": 1,
                    "bbox": {
                        "l": 10.0,
                        "t": 60.0,
                        "r": 200.0,
                        "b": 40.0,
                        "coord_origin": "BOTTOMLEFT",
                    },
                    "charspan": [0, 15],
                }
            ],
        },
        {
            "self_ref": "#/texts/2",
            "label": "formula",
            "text": "E = mc^2",
            "orig": "E = mc^2",
            "content_layer": "body",
            "prov": [],
        },
    ],
    "tables": [
        {
            "self_ref": "#/tables/0",
            "label": "table",
            "content_layer": "body",
            "data": {
                "num_rows": 2,
                "num_cols": 2,
                "grid": [
                    [{"text": "h1"}, {"text": "h2"}],
                    [{"text": "a"}, {"text": "b"}],
                ],
            },
            "prov": [],
        }
    ],
    "pictures": [
        {
            "self_ref": "#/pictures/0",
            "label": "picture",
            "content_layer": "body",
            "image": {"uri": "artifacts/img_000000.png", "mimetype": "image/png"},
            "prov": [],
        }
    ],
    "key_value_items": [],
    "form_items": [],
    "pages": {"1": {"size": {"width": 612.0, "height": 792.0}, "page_no": 1}},
}


def _install_fake_download(monkeypatch: pytest.MonkeyPatch) -> dict[str, int]:
    """Replace ``DoclingRawClient.download_into`` with a recorder that
    writes a synthetic raw bundle and a valid manifest."""
    import lightrag.parser.external.docling.client as client_mod

    counters = {"calls": 0}

    async def _fake_download(self, raw_dir: Path, source_file_path: Path, **_kwargs):
        counters["calls"] += 1
        raw_dir.mkdir(parents=True, exist_ok=True)
        main_json = raw_dir / "demo.json"
        main_json.write_text(json.dumps(_FAKE_DOCLING_JSON), encoding="utf-8")
        (raw_dir / "demo.md").write_text("# fake md", encoding="utf-8")
        art = raw_dir / "artifacts"
        art.mkdir(exist_ok=True)
        (art / "img_000000.png").write_bytes(b"\x89PNG fake")

        src_size, src_hash = compute_size_and_hash(source_file_path)
        crit_size, crit_hash = compute_size_and_hash(main_json)
        others = [
            ManifestFile(path="demo.md", size=(raw_dir / "demo.md").stat().st_size),
            ManifestFile(
                path="artifacts/img_000000.png",
                size=(art / "img_000000.png").stat().st_size,
            ),
        ]
        options_signature = compute_options_signature(
            tunable_env=snapshot_tunable_env(),
            fixed_constants=FIXED_CONSTANTS,
        )
        manifest = Manifest(
            engine="docling",
            source_content_hash=src_hash,
            source_size_bytes=src_size,
            source_filename_at_parse=source_file_path.name,
            critical_file=ManifestFile(
                path="demo.json", size=crit_size, sha256=crit_hash
            ),
            files=others,
            total_size_bytes=crit_size + sum(f.size for f in others),
            task_id=f"fake-{counters['calls']}",
            endpoint_signature="http://docling.test",
            options_signature=options_signature,
            extras={"fixed_constants": dict(FIXED_CONSTANTS)},
            downloaded_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
        )
        write_manifest(raw_dir, manifest)
        return manifest

    monkeypatch.setattr(client_mod.DoclingRawClient, "download_into", _fake_download)
    return counters


def _stub_pipeline(monkeypatch: pytest.MonkeyPatch, rag: LightRAG, src: Path) -> None:
    """Common pipeline-level stubs: avoid moving the source file and pin
    the file resolver to the synthetic path."""

    async def _noop_archive(_p: str) -> None:
        return None

    import lightrag.pipeline as pipeline_module

    monkeypatch.setattr(
        pipeline_module,
        "archive_docx_source_after_full_docs_sync",
        _noop_archive,
    )
    monkeypatch.setattr(rag, "_resolve_source_file_for_parser", lambda _p: str(src))


def _seed_doc_status(rag: LightRAG, doc_id: str) -> Any:
    return rag.doc_status.upsert(
        {
            doc_id: {
                "status": "PARSING",
                "content_summary": "",
                "content_length": 0,
                "chunks_count": 0,
                "chunks_list": [],
                "created_at": "2026-05-18T00:00:00+00:00",
                "updated_at": "2026-05-18T00:00:00+00:00",
                "file_path": "demo.pdf",
                "track_id": "trk",
                "content_hash": "",
                "metadata": {},
            }
        }
    )


# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------


@pytest.mark.offline
def test_parse_docling_emits_compliant_sidecar(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    async def _run() -> None:
        monkeypatch.setenv("DOCLING_ENDPOINT", "http://docling.test")
        counters = _install_fake_download(monkeypatch)

        input_dir = tmp_path / "inputs" / "ws"
        input_dir.mkdir(parents=True)
        src = input_dir / "demo.pdf"
        src.write_bytes(b"PDFPDF" * 256)

        rag = _new_rag(tmp_path)
        await rag.initialize_storages()
        try:
            _stub_pipeline(monkeypatch, rag, src)
            doc_id = "doc-abcdef0123456789abcdef0123456789"
            await _seed_doc_status(rag, doc_id)

            parsed = await _parse_via_registry(
                rag,
                "docling",
                doc_id=doc_id,
                file_path="demo.pdf",
                content_data={},
            )
            assert counters["calls"] == 1

            parsed_dir = Path(parsed["blocks_path"]).parent
            assert parsed["parse_format"] == FULL_DOCS_FORMAT_LIGHTRAG
            assert parsed_dir.name == "demo.pdf.parsed"

            files = {p.name for p in parsed_dir.iterdir() if p.is_file()}
            assert "demo.blocks.jsonl" in files
            assert "demo.tables.json" in files
            assert "demo.drawings.json" in files
            assert "demo.equations.json" in files
            assert (parsed_dir / "demo.blocks.assets").is_dir()
            assert (parsed_dir / "demo.blocks.assets" / "img_000000.png").is_file()

            blocks_raw = (parsed_dir / "demo.blocks.jsonl").read_text()
            lines = blocks_raw.splitlines()
            meta = json.loads(lines[0])
            rows = [json.loads(line) for line in lines[1:]]
            assert meta["parse_engine"] == "docling"
            assert meta["bbox_attributes"] == {"origin": "LEFTBOTTOM"}
            assert "max" not in meta["bbox_attributes"]
            assert "page_sizes" not in meta["bbox_attributes"]
            assert meta["table_file"] is True
            assert meta["drawing_file"] is True
            assert meta["equation_file"] is True
            # No label="title" in the fixture (matches the typical PDF case
            # where docling produces only section_headers) → doc_title falls
            # back to the document stem.
            assert meta["doc_title"] == "demo"

            contents = " ".join(row.get("content", "") for row in rows)
            assert '<table id="tb-' in contents
            assert "<drawing" in contents
            assert "<equation" in contents

            # Raw bundle preserved next to sidecar
            raw_dir = parsed_dir.parent / "demo.pdf.docling_raw"
            assert (raw_dir / "_manifest.json").is_file()
            assert (raw_dir / "demo.json").is_file()
            assert (raw_dir / "demo.md").is_file()
            assert (raw_dir / "artifacts" / "img_000000.png").is_file()

            # Drawing path correctly resolved
            drawings = json.loads((parsed_dir / "demo.drawings.json").read_text())[
                "drawings"
            ]
            (drawing_id, drawing_item) = next(iter(drawings.items()))
            assert drawing_id.startswith("im-")
            assert drawing_item["path"] == "demo.blocks.assets/img_000000.png"

            # Table self_ref propagated
            tables = json.loads((parsed_dir / "demo.tables.json").read_text())["tables"]
            (_, table_item) = next(iter(tables.items()))
            assert table_item.get("self_ref") == "#/tables/0"
        finally:
            await rag.finalize_storages()

    asyncio.new_event_loop().run_until_complete(_run())


@pytest.mark.offline
def test_parse_docling_cache_hit_skips_download(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    async def _run() -> None:
        monkeypatch.setenv("DOCLING_ENDPOINT", "http://docling.test")
        counters = _install_fake_download(monkeypatch)

        input_dir = tmp_path / "inputs" / "ws"
        input_dir.mkdir(parents=True)
        src = input_dir / "demo.pdf"
        src.write_bytes(b"PDFPDF" * 256)

        rag = _new_rag(tmp_path)
        await rag.initialize_storages()
        try:
            _stub_pipeline(monkeypatch, rag, src)
            doc_id = "doc-abcdef0123456789abcdef0123456789"
            await _seed_doc_status(rag, doc_id)

            await _parse_via_registry(
                rag,
                "docling",
                doc_id=doc_id,
                file_path="demo.pdf",
                content_data={},
            )
            assert counters["calls"] == 1

            await _parse_via_registry(
                rag,
                "docling",
                doc_id=doc_id,
                file_path="demo.pdf",
                content_data={},
            )
            assert counters["calls"] == 1, "cache hit must not re-download"

            monkeypatch.setenv("LIGHTRAG_FORCE_REPARSE_DOCLING", "true")
            await _parse_via_registry(
                rag,
                "docling",
                doc_id=doc_id,
                file_path="demo.pdf",
                content_data={},
            )
            assert counters["calls"] == 2
        finally:
            await rag.finalize_storages()

    asyncio.new_event_loop().run_until_complete(_run())


@pytest.mark.offline
def test_parse_docling_cache_invalidates_on_source_change(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    async def _run() -> None:
        monkeypatch.setenv("DOCLING_ENDPOINT", "http://docling.test")
        counters = _install_fake_download(monkeypatch)

        input_dir = tmp_path / "inputs" / "ws"
        input_dir.mkdir(parents=True)
        src = input_dir / "demo.pdf"
        src.write_bytes(b"PDFPDF" * 256)

        rag = _new_rag(tmp_path)
        await rag.initialize_storages()
        try:
            _stub_pipeline(monkeypatch, rag, src)
            doc_id = "doc-abcdef0123456789abcdef0123456789"
            await _seed_doc_status(rag, doc_id)

            await _parse_via_registry(
                rag,
                "docling",
                doc_id=doc_id,
                file_path="demo.pdf",
                content_data={},
            )
            assert counters["calls"] == 1

            data = src.read_bytes()
            src.write_bytes(b"\x00" + data[1:])

            await _parse_via_registry(
                rag,
                "docling",
                doc_id=doc_id,
                file_path="demo.pdf",
                content_data={},
            )
            assert counters["calls"] == 2
        finally:
            await rag.finalize_storages()

    asyncio.new_event_loop().run_until_complete(_run())


@pytest.mark.offline
def test_parse_docling_options_signature_invalidates_cache(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    async def _run() -> None:
        monkeypatch.setenv("DOCLING_ENDPOINT", "http://docling.test")
        counters = _install_fake_download(monkeypatch)

        input_dir = tmp_path / "inputs" / "ws"
        input_dir.mkdir(parents=True)
        src = input_dir / "demo.pdf"
        src.write_bytes(b"PDFPDF" * 256)

        rag = _new_rag(tmp_path)
        await rag.initialize_storages()
        try:
            _stub_pipeline(monkeypatch, rag, src)
            doc_id = "doc-abcdef0123456789abcdef0123456789"
            await _seed_doc_status(rag, doc_id)

            await _parse_via_registry(
                rag,
                "docling",
                doc_id=doc_id,
                file_path="demo.pdf",
                content_data={},
            )
            assert counters["calls"] == 1

            # Flip an env var that participates in the options signature
            monkeypatch.setenv("DOCLING_OCR_LANG", "en,zh")
            await _parse_via_registry(
                rag,
                "docling",
                doc_id=doc_id,
                file_path="demo.pdf",
                content_data={},
            )
            assert counters["calls"] == 2, (
                "DOCLING_OCR_LANG change must invalidate the bundle cache"
            )
        finally:
            await rag.finalize_storages()

    asyncio.new_event_loop().run_until_complete(_run())


@pytest.mark.offline
def test_parse_docling_endpoint_signature_invalidates_cache(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    async def _run() -> None:
        monkeypatch.setenv("DOCLING_ENDPOINT", "http://docling.test")
        counters = _install_fake_download(monkeypatch)

        input_dir = tmp_path / "inputs" / "ws"
        input_dir.mkdir(parents=True)
        src = input_dir / "demo.pdf"
        src.write_bytes(b"PDFPDF" * 256)

        rag = _new_rag(tmp_path)
        await rag.initialize_storages()
        try:
            _stub_pipeline(monkeypatch, rag, src)
            doc_id = "doc-abcdef0123456789abcdef0123456789"
            await _seed_doc_status(rag, doc_id)

            await _parse_via_registry(
                rag,
                "docling",
                doc_id=doc_id,
                file_path="demo.pdf",
                content_data={},
            )
            assert counters["calls"] == 1

            # Pointing at a different docling-serve instance must not silently
            # reuse a bundle that was produced by the previous one.
            monkeypatch.setenv("DOCLING_ENDPOINT", "http://docling-other.test")
            await _parse_via_registry(
                rag,
                "docling",
                doc_id=doc_id,
                file_path="demo.pdf",
                content_data={},
            )
            assert counters["calls"] == 2, (
                "DOCLING_ENDPOINT change must invalidate the bundle cache"
            )
        finally:
            await rag.finalize_storages()

    asyncio.new_event_loop().run_until_complete(_run())


@pytest.mark.offline
def test_parse_docling_zero_blocks_raises(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """When the docling bundle yields no body blocks (e.g. everything was
    classified as furniture/background) ``parse_docling`` must fail loudly
    so the document is marked failed — never persist a half-baked sidecar.
    """

    async def _run() -> None:
        monkeypatch.setenv("DOCLING_ENDPOINT", "http://docling.test")

        # Install a fake download that writes a valid bundle whose body has
        # no children — the adapter then produces zero IR blocks.
        import lightrag.parser.external.docling.client as client_mod

        empty_json: dict[str, Any] = {
            "schema_name": "DoclingDocument",
            "version": "1.10.0",
            "origin": {"filename": "demo.pdf", "mimetype": "application/pdf"},
            "body": {
                "self_ref": "#/body",
                "children": [],
                "content_layer": "body",
                "label": "unspecified",
            },
            "groups": [],
            "texts": [],
            "tables": [],
            "pictures": [],
            "key_value_items": [],
            "form_items": [],
            "pages": {},
        }

        async def _fake_download(
            self, raw_dir: Path, source_file_path: Path, **_kwargs
        ):
            raw_dir.mkdir(parents=True, exist_ok=True)
            main_json = raw_dir / "demo.json"
            main_json.write_text(json.dumps(empty_json), encoding="utf-8")
            (raw_dir / "demo.md").write_text("# empty", encoding="utf-8")

            src_size, src_hash = compute_size_and_hash(source_file_path)
            crit_size, crit_hash = compute_size_and_hash(main_json)
            others = [
                ManifestFile(path="demo.md", size=(raw_dir / "demo.md").stat().st_size),
            ]
            options_signature = compute_options_signature(
                tunable_env=snapshot_tunable_env(),
                fixed_constants=FIXED_CONSTANTS,
            )
            manifest = Manifest(
                engine="docling",
                source_content_hash=src_hash,
                source_size_bytes=src_size,
                source_filename_at_parse=source_file_path.name,
                critical_file=ManifestFile(
                    path="demo.json", size=crit_size, sha256=crit_hash
                ),
                files=others,
                total_size_bytes=crit_size + sum(f.size for f in others),
                task_id="fake-empty",
                endpoint_signature="http://docling.test",
                options_signature=options_signature,
                extras={"fixed_constants": dict(FIXED_CONSTANTS)},
                downloaded_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
            )
            write_manifest(raw_dir, manifest)
            return manifest

        monkeypatch.setattr(
            client_mod.DoclingRawClient, "download_into", _fake_download
        )

        input_dir = tmp_path / "inputs" / "ws"
        input_dir.mkdir(parents=True)
        src = input_dir / "demo.pdf"
        src.write_bytes(b"PDFPDF" * 256)

        rag = _new_rag(tmp_path)
        await rag.initialize_storages()
        try:
            _stub_pipeline(monkeypatch, rag, src)
            doc_id = "doc-abcdef0123456789abcdef0123456789"
            await _seed_doc_status(rag, doc_id)

            with pytest.raises(ValueError, match="zero blocks"):
                await _parse_via_registry(
                    rag,
                    "docling",
                    doc_id=doc_id,
                    file_path="demo.pdf",
                    content_data={},
                )

            # Sidecar must NOT have been emitted: ``write_sidecar`` is reached
            # only after the zero-blocks check, so no ``*.blocks.jsonl`` may
            # exist anywhere under the workspace.
            blocks_files = list(tmp_path.rglob("*.blocks.jsonl"))
            assert not blocks_files, (
                f"sidecar emitted despite zero-blocks failure: {blocks_files}"
            )
        finally:
            await rag.finalize_storages()

    asyncio.new_event_loop().run_until_complete(_run())
