"""Spec-compliant sidecar writer.

This module is the *single executable specification* of the LightRAG sidecar
format (``docs/LightRAGSidecarFormat-zh.md``). Engine adapters hand it an
:class:`IRDoc`; it emits the ``*.parsed/`` directory.

Responsibilities (none of these belong in adapters):

- id allocation: ``tb-/im-/eq-<doc_hash>-NNNN`` (4-digit zero-padded,
  global per-doc sequence)
- placeholder rendering: ``{{TBL:k}}`` / ``{{IMG:k}}`` / ``{{EQ:k}}`` /
  ``{{EQI:k}}`` → spec-shaped XML-style tags
- blockid computation: ``md5(doc_id:block_index:heading:content)``
- assets dir creation and file copying; ``asset_dir`` flag in meta is
  derived from "directory exists and is non-empty"
- merged_text + document_hash
- meta line shape (spec §3.1)
- conditional writes: ``tables.json`` / ``drawings.json`` / ``equations.json``
  appear only when their dict is non-empty
"""

from __future__ import annotations

import hashlib
import json
import re
import shutil
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

from lightrag.constants import FULL_DOCS_FORMAT_LIGHTRAG
from lightrag.sidecar.ir import (
    AssetSpec,
    IRBlock,
    IRDoc,
    IRDrawing,
    IREquation,
    IRTable,
)
from lightrag.sidecar.placeholders import (
    render_drawing_tag,
    render_equation_tag,
    render_table_tag,
    render_template,
    table_body_for_rows,
)
from lightrag.table_markup import header_grid_to_thead_html
from lightrag.utils import logger, strip_control_characters


# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------


_VALID_BLOCK_DRAWING_PATH_STYLES = {"with_prefix", "basename_only"}


def write_sidecar(
    ir: IRDoc,
    *,
    parsed_dir: Path,
    doc_id: str,
    engine: str,
    clean_parsed_dir: bool = True,
    block_drawing_path_style: str = "with_prefix",
) -> dict[str, Any]:
    """Emit a spec-compliant ``*.parsed/`` directory from an IR.

    Args:
        ir: Document IR produced by an engine adapter.
        parsed_dir: Output directory. By default cleared and recreated; the
            caller is responsible for placing it under
            ``__parsed__/<base>.parsed/``.
        doc_id: ``doc-<md5>``; ``doc_hash`` for sidecar ids is the 32-char
            tail after stripping the ``doc-`` prefix.
        engine: One of ``native`` / ``mineru`` / ``docling`` / ``legacy``;
            written verbatim to ``meta.parse_engine``.
        clean_parsed_dir: When True (default) the writer ``rmtree``s
            ``parsed_dir`` before writing. Set to False when the caller has
            already pre-populated the directory with side artifacts that
            must survive — e.g. the native docx adapter pre-extracts image
            bytes into ``<base>.blocks.assets/`` before the writer runs,
            and passing ``AssetSpec.source=None`` lets the writer record
            them without copying.
        block_drawing_path_style: How ``<drawing path="...">`` in
            ``blocks.jsonl`` resolves the asset path. ``"with_prefix"``
            (default) renders ``<base>.blocks.assets/<filename>`` — matches
            the path stored in ``drawings.json``. ``"basename_only"``
            renders just ``<filename>``; legacy native docx convention
            (downstream consumers read the file path from ``drawings.json``,
            not from this attribute, so the basename-only form is purely
            cosmetic but kept for byte-equivalence with the original
            adapter).

    Returns:
        Dict shaped like the pipeline's existing ``parsed_data`` payload:
        ``{doc_id, file_path, parse_format, content, blocks_path}``.
        ``file_path`` is ``ir.document_name``; the caller resolves it to the
        actual on-disk path it wants persisted.
    """
    if block_drawing_path_style not in _VALID_BLOCK_DRAWING_PATH_STYLES:
        allowed = ", ".join(sorted(_VALID_BLOCK_DRAWING_PATH_STYLES))
        raise ValueError(
            f"block_drawing_path_style must be one of {allowed}, "
            f"got {block_drawing_path_style!r}"
        )

    if clean_parsed_dir and parsed_dir.exists():
        shutil.rmtree(parsed_dir)
    parsed_dir.mkdir(parents=True, exist_ok=True)

    base_name = Path(ir.document_name).stem or ir.document_name
    blocks_path = parsed_dir / f"{base_name}.blocks.jsonl"
    tables_path = parsed_dir / f"{base_name}.tables.json"
    drawings_path = parsed_dir / f"{base_name}.drawings.json"
    equations_path = parsed_dir / f"{base_name}.equations.json"
    assets_dir = parsed_dir / f"{base_name}.blocks.assets"

    # ``clean_parsed_dir=False`` is reserved for callers that pre-populate
    # the directory with artifacts that must survive (e.g. the native docx
    # adapter pre-extracts assets). If a stale ``blocks.jsonl`` is sitting
    # there, the caller forgot to pre-clean — warn so the leftover doesn't
    # get silently overwritten with partially-stale neighbors.
    if not clean_parsed_dir and blocks_path.exists():
        logger.warning(
            "[sidecar] clean_parsed_dir=False but %s already exists; "
            "caller is expected to pre-clean before invoking write_sidecar",
            blocks_path,
        )

    # Stage 1: realize assets first so drawings can carry resolved paths.
    asset_paths = _materialize_assets(ir.assets, assets_dir)

    # Stage 2: walk blocks, allocate ids, render templates, accumulate
    # sidecar item dicts and blocks.jsonl lines.
    doc_hash = doc_id.removeprefix("doc-")
    tables: dict[str, dict[str, Any]] = {}
    drawings: dict[str, dict[str, Any]] = {}
    equations: dict[str, dict[str, Any]] = {}
    blocks_lines: list[str] = []
    merged_parts: list[str] = []

    table_seq = 0
    drawing_seq = 0
    equation_seq = 0

    asset_prefix = f"{assets_dir.name}/"

    # ``block_index`` in the blockid hash refers to the position in the
    # SOURCE block list (``enumerate`` over ``ir.blocks``), not the emitted
    # position. Otherwise an editor turning a previously-non-empty block
    # into an empty one — which then gets dropped — would shift the
    # blockids of every block after it; we want stable ids across edits.
    for block_index, block in enumerate(ir.blocks):
        # Allocate ids for items declared on this block. Order: tables ->
        # drawings -> equations (per-block deterministic; the global
        # sequence advances across blocks).
        table_id_by_key: dict[str, str] = {}
        for table in block.tables:
            table_seq += 1
            tb_id = f"tb-{doc_hash}-{table_seq:04d}"
            table_id_by_key[table.placeholder_key] = tb_id

        drawing_id_by_key: dict[str, str] = {}
        for drawing in block.drawings:
            drawing_seq += 1
            im_id = f"im-{doc_hash}-{drawing_seq:04d}"
            drawing_id_by_key[drawing.placeholder_key] = im_id

        equation_id_by_key: dict[str, str] = {}
        for equation in block.equations:
            if not equation.is_block:
                continue
            equation_seq += 1
            eq_id = f"eq-{doc_hash}-{equation_seq:04d}"
            equation_id_by_key[equation.placeholder_key] = eq_id

        # Render placeholder template.
        rendered = _render_block_content(
            block,
            table_id_by_key=table_id_by_key,
            drawing_id_by_key=drawing_id_by_key,
            equation_id_by_key=equation_id_by_key,
            asset_paths=asset_paths,
            asset_prefix=asset_prefix,
            block_drawing_path_style=block_drawing_path_style,
        )

        # Strip C0 control/separator chars (incl. \x1c-\x1f FS/GS/RS/US) before
        # whitespace-trimming so they cannot survive into blockid, blocks.jsonl
        # content (the chunk source), merged_text/full_docs content, or
        # document_hash. A block that is only control chars + whitespace then
        # collapses to empty and is dropped below. No-op for clean input, so
        # existing blockids/hashes are preserved.
        rendered = strip_control_characters(rendered).strip()
        if not rendered:
            # Drop empty blocks entirely — neither blocks.jsonl entry nor
            # sidecar items (the items were tied to the placeholder; if it
            # vanished, the items are orphans). This mirrors the existing
            # native_docx behaviour and ensures merged_text is contiguous.
            continue

        blockid = hashlib.md5(
            f"{doc_id}:{block_index}:{block.heading}:{rendered}".encode("utf-8")
        ).hexdigest()

        # Realize per-block sidecar item dicts now that blockid is known.
        # Defensive: an adapter that declares an item on block.tables /
        # drawings / equations but omits the matching ``{{TBL/IMG/EQ:k}}``
        # token from ``content_template`` would leave the rendered text
        # without the corresponding tag. We detect that by checking whether
        # the allocated id (which is doc-unique) appears in the rendered
        # output, warn, and skip the sidecar entry — otherwise the per-
        # modality JSON would reference a blockid whose body never names it.
        for table in block.tables:
            tb_id = table_id_by_key[table.placeholder_key]
            if tb_id not in rendered:
                logger.warning(
                    "[sidecar] orphan table id=%s on block %d "
                    "(placeholder %r not referenced in content_template); "
                    "skipping sidecar entry",
                    tb_id,
                    block_index,
                    table.placeholder_key,
                )
                continue
            tables[tb_id] = _table_item_dict(
                tb_id, blockid, block.heading, block.parent_headings, table
            )
        for drawing in block.drawings:
            im_id = drawing_id_by_key[drawing.placeholder_key]
            if im_id not in rendered:
                logger.warning(
                    "[sidecar] orphan drawing id=%s on block %d "
                    "(placeholder %r not referenced in content_template); "
                    "skipping sidecar entry",
                    im_id,
                    block_index,
                    drawing.placeholder_key,
                )
                continue
            drawings[im_id] = _drawing_item_dict(
                im_id,
                blockid,
                block.heading,
                block.parent_headings,
                drawing,
                asset_paths,
                asset_prefix,
            )
        for equation in block.equations:
            if not equation.is_block:
                continue
            eq_id = equation_id_by_key[equation.placeholder_key]
            if eq_id not in rendered:
                logger.warning(
                    "[sidecar] orphan equation id=%s on block %d "
                    "(placeholder %r not referenced in content_template); "
                    "skipping sidecar entry",
                    eq_id,
                    block_index,
                    equation.placeholder_key,
                )
                continue
            equations[eq_id] = _equation_item_dict(
                eq_id, blockid, block.heading, block.parent_headings, equation
            )

        row: dict[str, Any] = {
            "type": "content",
            "blockid": blockid,
            "format": "plain_text",
            "content": rendered,
            "heading": block.heading,
            "parent_headings": list(block.parent_headings),
            "level": int(block.level),
            "session_type": block.session_type or "body",
            "table_slice": block.table_slice or "none",
            "positions": [p.to_jsonable() for p in block.positions],
        }
        if block.table_header:
            row["table_header"] = block.table_header
        blocks_lines.append(json.dumps(row, ensure_ascii=False))
        merged_parts.append(rendered)

    # Stage 3: doc-level metadata.
    merged_text = "\n\n".join(p for p in merged_parts if p.strip())
    document_hash = hashlib.sha256(merged_text.encode("utf-8")).hexdigest()
    parse_time = datetime.now(timezone.utc).isoformat()

    asset_dir_present = assets_dir.exists() and any(assets_dir.iterdir())
    if not asset_dir_present and assets_dir.exists():
        try:
            assets_dir.rmdir()
        except OSError:
            pass

    meta: dict[str, Any] = {
        "type": "meta",
        "format": "lightrag",
        "version": "1.0",
        "document_name": ir.document_name,
        "document_format": ir.document_format,
        "document_hash": f"sha256:{document_hash}",
        "table_file": bool(tables),
        "equation_file": bool(equations),
        "drawing_file": bool(drawings),
        "asset_dir": asset_dir_present,
    }
    # Engine-recorded metadata (e.g. engine_version); omitted entirely when the
    # engine recorded nothing so the common native/markdown case doesn't carry a
    # dead ``{}`` field. Inserted here to keep the meta key order stable.
    split_option = dict(ir.split_option or {})
    if split_option:
        meta["split_option"] = split_option
    meta.update(
        {
            "blocks": len(blocks_lines),
            "doc_id": doc_id,
            "parse_engine": engine,
            "parse_time": parse_time,
            "doc_title": ir.doc_title,
        }
    )
    if ir.bbox_attributes is not None:
        meta["bbox_attributes"] = dict(ir.bbox_attributes)

    blocks_path.write_text(
        "\n".join([json.dumps(meta, ensure_ascii=False)] + blocks_lines) + "\n",
        encoding="utf-8",
    )

    # Sidecar JSONs end with a trailing newline (POSIX text-file convention;
    # also keeps end-of-file linters / pre-commit hooks happy and matches the
    # ``blocks.jsonl`` convention above).
    if tables:
        tables_path.write_text(
            json.dumps(
                {"version": "1.0", "tables": tables},
                ensure_ascii=False,
                indent=2,
            )
            + "\n",
            encoding="utf-8",
        )
    if drawings:
        drawings_path.write_text(
            json.dumps(
                {"version": "1.0", "drawings": drawings},
                ensure_ascii=False,
                indent=2,
            )
            + "\n",
            encoding="utf-8",
        )
    if equations:
        equations_path.write_text(
            json.dumps(
                {"version": "1.0", "equations": equations},
                ensure_ascii=False,
                indent=2,
            )
            + "\n",
            encoding="utf-8",
        )

    logger.info(
        "[sidecar] wrote %d blocks for doc_id=%s "
        "(%d tables, %d drawings, %d equations, assets=%s, engine=%s)",
        len(blocks_lines),
        doc_id,
        len(tables),
        len(drawings),
        len(equations),
        asset_dir_present,
        engine,
    )

    return {
        "doc_id": doc_id,
        "file_path": ir.document_name,
        "parse_format": FULL_DOCS_FORMAT_LIGHTRAG,
        "content": merged_text,
        "blocks_path": str(blocks_path),
    }


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _materialize_assets(
    assets: list[AssetSpec],
    assets_dir: Path,
) -> dict[str, str]:
    """Materialize :class:`AssetSpec` objects into ``assets_dir``.

    Returns: ``{ref: filename_inside_assets_dir}``.

    Collision policy: if two specs map to the same target name, the second
    gets a ``-2``, ``-3``, ... suffix on the stem. We never overwrite a file
    we've already produced.
    """
    if not assets:
        return {}

    assets_dir.mkdir(parents=True, exist_ok=True)
    out: dict[str, str] = {}
    used_names: set[str] = set()

    for spec in assets:
        target_name = _allocate_unique_name(spec.suggested_name, used_names)
        target_path = assets_dir / target_name
        try:
            target_path.resolve().relative_to(assets_dir.resolve())
        except ValueError:
            logger.warning(
                "[sidecar] unsafe asset target for ref=%s (%s); skipping",
                spec.ref,
                spec.suggested_name,
            )
            continue
        if isinstance(spec.source, (str, Path)):
            src_path = Path(spec.source)
            if not src_path.exists():
                logger.warning(
                    "[sidecar] asset source missing for ref=%s (%s); skipping copy",
                    spec.ref,
                    src_path,
                )
                continue
            if src_path.resolve() != target_path.resolve():
                shutil.copyfile(src_path, target_path)
        elif isinstance(spec.source, bytes):
            target_path.write_bytes(spec.source)
        elif spec.source is None:
            # Assumed already on disk at the target location (native_docx
            # writes assets during extraction). Verify presence; warn if
            # missing.
            if not target_path.exists():
                logger.warning(
                    "[sidecar] asset ref=%s declared in place but %s is absent",
                    spec.ref,
                    target_path,
                )
                continue
        else:
            logger.warning(
                "[sidecar] unsupported AssetSpec.source type for ref=%s: %s",
                spec.ref,
                type(spec.source).__name__,
            )
            continue
        used_names.add(target_name)
        out[spec.ref] = target_name

    return out


def _allocate_unique_name(suggested: str, used: set[str]) -> str:
    """Make ``suggested`` unique within ``used``: ``foo.png`` → ``foo-2.png``."""
    suggested = _safe_asset_filename(suggested)
    if suggested not in used:
        return suggested
    stem = Path(suggested).stem
    suffix = Path(suggested).suffix
    n = 2
    while True:
        cand = f"{stem}-{n}{suffix}"
        if cand not in used:
            return cand
        n += 1


def _safe_asset_filename(suggested: str) -> str:
    """Collapse parser-suggested asset names to a safe filename."""
    name = Path(str(suggested).replace("\\", "/")).name
    name = "".join(c for c in name.strip() if ord(c) >= 32 and c != "\x7f").strip(".")
    return name or "asset"


def _render_block_content(
    block: IRBlock,
    *,
    table_id_by_key: dict[str, str],
    drawing_id_by_key: dict[str, str],
    equation_id_by_key: dict[str, str],
    asset_paths: dict[str, str],
    asset_prefix: str,
    block_drawing_path_style: str = "with_prefix",
) -> str:
    """Expand placeholder tokens in ``block.content_template``."""

    tables_by_key = {t.placeholder_key: t for t in block.tables}
    drawings_by_key = {d.placeholder_key: d for d in block.drawings}
    equations_by_key = {e.placeholder_key: e for e in block.equations}

    def _table(key: str) -> str:
        table = tables_by_key.get(key)
        if table is None:
            return ""
        tb_id = table_id_by_key.get(key, "")
        if table.body_override is not None:
            # Verbatim block-text body — used by adapters that need to
            # preserve the parser's original whitespace/escaping (native
            # docx). Sidecar entry's ``content`` field still gets the
            # canonical ``table_body_for_rows`` encoding via
            # ``_table_item_dict``.
            fmt = "json" if table.rows is not None else "html"
            return render_table_tag(tb_id, fmt, table.body_override)
        if table.rows is not None:
            return render_table_tag(tb_id, "json", table_body_for_rows(table.rows))
        return render_table_tag(tb_id, "html", table.html or "")

    def _drawing(key: str) -> str:
        drawing = drawings_by_key.get(key)
        if drawing is None:
            return ""
        im_id = drawing_id_by_key.get(key, "")
        if drawing.path_override is not None:
            # Verbatim external/linked reference — pass through unchanged.
            path = drawing.path_override
        else:
            filename = asset_paths.get(drawing.asset_ref, "")
            if not filename:
                path = ""
            elif block_drawing_path_style == "basename_only":
                path = filename
            else:
                path = f"{asset_prefix}{filename}"
        return render_drawing_tag(
            im_id,
            drawing.fmt,
            drawing.caption,
            path,
            drawing.src,
        )

    def _equation(key: str) -> str:
        eq = equations_by_key.get(key)
        if eq is None:
            return ""
        if not eq.is_block:
            # Adapter mistake: an EQ token should only be used for block
            # equations. Treat as inline to avoid a dangling token.
            return render_equation_tag(None, eq.latex, eq.caption)
        eq_id = equation_id_by_key.get(key, "")
        return render_equation_tag(eq_id, eq.latex, eq.caption)

    def _inline_equation(key: str) -> str:
        eq = equations_by_key.get(key)
        if eq is None:
            return ""
        return render_equation_tag(None, eq.latex, eq.caption)

    return render_template(
        block.content_template,
        table_renderer=_table,
        drawing_renderer=_drawing,
        equation_renderer=_equation,
        inline_equation_renderer=_inline_equation,
    )


def _table_item_dict(
    table_id: str,
    blockid: str,
    heading: str,
    parent_headings: list[str],
    table: IRTable,
) -> dict[str, Any]:
    if table.rows is not None:
        fmt = "json"
        content = table_body_for_rows(table.rows)
    else:
        fmt = "html"
        content = table.html or ""

    item: dict[str, Any] = {
        "id": table_id,
        "blockid": blockid,
        "heading": heading,
        "parent_headings": list(parent_headings),
        "dimension": [int(table.num_rows), int(table.num_cols)],
        "format": fmt,
        "content": content,
        "caption": table.caption,
        "footnotes": list(table.footnotes),
    }
    if table.table_header is not None:
        # The header's stored format follows the table's format (spec §5):
        #   * HTML tables → a raw ``<thead>…</thead>`` string stored verbatim so
        #     merged cells (``rowspan``/``colspan``) survive; a grid supplied for
        #     an HTML table is rendered to a (span-less) ``<thead>`` as fallback.
        #   * JSON tables → a JSON 2-D array string.
        if fmt == "html":
            if isinstance(table.table_header, str):
                item["table_header"] = table.table_header
            else:
                item["table_header"] = header_grid_to_thead_html(table.table_header)
        elif isinstance(table.table_header, str):
            raise ValueError(
                f"JSON table {table_id!r} has a string table_header "
                f"({table.table_header[:40]!r}…); JSON tables require a 2-D grid"
            )
        else:
            item["table_header"] = json.dumps(table.table_header, ensure_ascii=False)
    if table.self_ref:
        item["self_ref"] = table.self_ref
    if table.extras:
        item["extras"] = dict(table.extras)
    return item


def _drawing_item_dict(
    drawing_id: str,
    blockid: str,
    heading: str,
    parent_headings: list[str],
    drawing: IRDrawing,
    asset_paths: dict[str, str],
    asset_prefix: str,
) -> dict[str, Any]:
    if drawing.path_override is not None:
        path = drawing.path_override
    else:
        filename = asset_paths.get(drawing.asset_ref, "")
        path = f"{asset_prefix}{filename}" if filename else ""
    item: dict[str, Any] = {
        "id": drawing_id,
        "blockid": blockid,
        "heading": heading,
        "parent_headings": list(parent_headings),
        "format": drawing.fmt,
        "path": path,
        "src": drawing.src,
        "caption": drawing.caption,
        "footnotes": list(drawing.footnotes),
    }
    if drawing.self_ref:
        item["self_ref"] = drawing.self_ref
    if drawing.extras:
        item["extras"] = dict(drawing.extras)
    return item


_LATEX_DOLLAR_RE = re.compile(r"^\s*\$\$?(.+?)\$\$?\s*$", re.DOTALL)


def _strip_latex_dollar_wrappers(latex: str) -> str:
    """Strip leading/trailing ``$``/``$$`` wrappers from a latex string.

    ``equations.json`` stores clean latex (per the MinerU adapter contract:
    ``blocks.jsonl`` keeps the parser's raw form so the rendered
    ``<equation>`` body is byte-identical to the source, while the
    per-equation sidecar carries delimiter-free latex). Leaves strings
    without wrappers untouched.
    """
    if not latex:
        return latex
    m = _LATEX_DOLLAR_RE.match(latex)
    return m.group(1).strip() if m else latex.strip()


def _equation_item_dict(
    eq_id: str,
    blockid: str,
    heading: str,
    parent_headings: list[str],
    equation: IREquation,
) -> dict[str, Any]:
    item: dict[str, Any] = {
        "id": eq_id,
        "blockid": blockid,
        "heading": heading,
        "parent_headings": list(parent_headings),
        "format": "latex",
        "content": _strip_latex_dollar_wrappers(equation.latex),
        "caption": equation.caption,
        "footnotes": list(equation.footnotes),
    }
    if equation.self_ref:
        item["self_ref"] = equation.self_ref
    if equation.extras:
        item["extras"] = dict(equation.extras)
    return item
