"""Pure helpers over a serialized `DoclingDocument` dict.

No I/O, no Neo4j. Shared between:
- `infra.neo4j.tree_writer` — persists the tree into Neo4j during the Maintain
  step (IngestionPipeline).
- `infra.docling_graph` — builds an in-memory `GraphPayload` from the SQLite
  `document_json` blob for the reasoning-trace viewer.

Keep this module the single source of truth for how we read Docling's own
structure, so the two consumers can't drift.
"""

from __future__ import annotations

from collections.abc import Iterator
from typing import Any

# Docling label -> specific Neo4j/Cytoscape label. Every element carries the
# generic :Element tag too. Kept 1:1 with docling-core's label taxonomy so the
# projection is a faithful mirror of the DoclingDocument.
LABEL_MAP: dict[str, str] = {
    "section_header": "SectionHeader",
    "title": "SectionHeader",
    "paragraph": "Paragraph",
    "text": "Paragraph",
    "list_item": "ListItem",
    "list": "List",  # distinct from :ListItem — a list is a container
    "inline": "Paragraph",  # see issue #197 — collapsed into one paragraph node
    "table": "Table",
    "picture": "Figure",
    "formula": "Formula",
    "code": "Code",
    "caption": "Caption",
    "footnote": "Footnote",
    "page_header": "PageHeader",
    "page_footer": "PageFooter",
    "key_value_area": "KeyValueArea",
    "form_area": "FormArea",
    "document_index": "DocumentIndex",
}
DEFAULT_LABEL = "TextElement"


def element_label(docling_label: str) -> str:
    return LABEL_MAP.get(docling_label.lower(), DEFAULT_LABEL)


def is_inline_group(item: dict[str, Any]) -> bool:
    """True iff `item` is a Docling InlineGroup (paragraph of mixed style runs).

    Docling represents an inline-styled paragraph as one entry in `groups[]`
    (label `inline`) plus N entries in `texts[]` (label `text`), one per style
    run. We collapse them into a single Paragraph projection — see #197.
    """
    return (item.get("label") or "").lower() == "inline"


def is_picture(item: dict[str, Any]) -> bool:
    """True iff `item` is a Docling PictureItem (figure or chart).

    A `picture` keeps its node in the graph (it IS the figure), but its
    `children` — internal text labels extracted from a flowchart, diagram,
    chart axis labels — are noise for graph readability and are skipped.
    Captions live in a separate `captions` field on the picture, not in
    `children`, so they are unaffected by this skip.
    """
    return (item.get("label") or "").lower() in {"picture", "chart"}


def iter_items(doc_data: dict[str, Any]) -> Iterator[tuple[str, dict[str, Any]]]:
    """Yield every item from texts/tables/pictures/groups with its source list key."""
    for key in ("texts", "tables", "pictures", "groups"):
        for item in doc_data.get(key, []) or []:
            yield key, item


def parent_ref(item: dict[str, Any]) -> str | None:
    parent = item.get("parent")
    if isinstance(parent, dict):
        return parent.get("$ref") or parent.get("cref")
    return None


def iter_provs(item: dict[str, Any]) -> list[dict[str, Any]]:
    """Flatten a Docling item's `prov[]` into a list of dict rows.

    A single item may have multiple provs when it spans page breaks or appears
    more than once in the layout. The returned dicts carry the original index
    under `order` so sequence is preserved.
    """
    provs = item.get("prov") or []
    rows: list[dict[str, Any]] = []
    for idx, p in enumerate(provs):
        bbox = p.get("bbox")
        l_, t_, r_, b_ = 0.0, 0.0, 0.0, 0.0
        if isinstance(bbox, dict):
            l_ = float(bbox.get("l", 0.0) or 0.0)
            t_ = float(bbox.get("t", 0.0) or 0.0)
            r_ = float(bbox.get("r", 0.0) or 0.0)
            b_ = float(bbox.get("b", 0.0) or 0.0)
        elif isinstance(bbox, list | tuple) and len(bbox) >= 4:
            l_, t_, r_, b_ = (float(x) for x in bbox[:4])
        coord_origin = (bbox.get("coord_origin") if isinstance(bbox, dict) else None) or "TOPLEFT"
        charspan = p.get("charspan") or []
        rows.append(
            {
                "order": idx,
                "page_no": p.get("page_no"),
                "bbox_l": l_,
                "bbox_t": t_,
                "bbox_r": r_,
                "bbox_b": b_,
                "coord_origin": coord_origin,
                "charspan_start": int(charspan[0]) if len(charspan) >= 1 else None,
                "charspan_end": int(charspan[1]) if len(charspan) >= 2 else None,
            }
        )
    return rows


def dfs_order(doc_data: dict[str, Any], skip_refs: set[str] | None = None) -> list[str]:
    """Return `self_ref`s in reading order (DFS pre-order from body).

    `skip_refs` (typically the set returned by `build_inline_index`) is omitted
    from the chain. Inline groups themselves are emitted but the walk does not
    recurse into their style-run children, so the resulting order references
    only nodes that survive the InlineGroup collapse.
    """
    skip = skip_refs or set()
    by_ref: dict[str, dict[str, Any]] = {}
    for _, item in iter_items(doc_data):
        ref = item.get("self_ref")
        if ref:
            by_ref[ref] = item
    body = doc_data.get("body") or {}
    order: list[str] = []

    def walk(children: list[dict[str, Any]] | None) -> None:
        if not children:
            return
        for ch in children:
            ref = ch.get("$ref") or ch.get("cref")
            if not ref or ref in skip:
                continue
            order.append(ref)
            child = by_ref.get(ref)
            if child and not is_inline_group(child):
                walk(child.get("children"))

    walk(body.get("children"))
    return order


def build_collapse_index(
    doc_data: dict[str, Any],
) -> tuple[set[str], dict[str, dict[str, Any]]]:
    """Pre-compute graph-projection collapses for a serialized DoclingDocument.

    Two cases produce noise nodes if mirrored 1:1 — see issue #197:

    1. **InlineGroup** — Docling emits one `groups[]` entry (label `inline`)
       plus N `texts[]` style runs. We collapse the children into the group,
       which is then projected as a single `:Paragraph` with concatenated
       text and the union of children's provs.
    2. **Picture / Chart** — internal text labels extracted from flowcharts,
       diagrams or chart axes hang off the picture's `children`. The picture
       node itself stays, but its descendants are skipped so the graph isn't
       drowned in dozens of tiny labels.

    Returns `(skip_refs, inline_meta)`:

    - `skip_refs`: every `self_ref` to drop from element / edge projections.
    - `inline_meta[group_ref]`: `{"text": str, "provs": list[dict]}` —
      override values for the inline group projection. Pictures don't have
      an entry here; they keep their own text/prov.
    """
    by_ref: dict[str, dict[str, Any]] = {}
    for _, item in iter_items(doc_data):
        ref = item.get("self_ref")
        if ref:
            by_ref[ref] = item

    skip_refs: set[str] = set()
    inline_meta: dict[str, dict[str, Any]] = {}

    for item in by_ref.values():
        ref = item.get("self_ref") or ""
        if not ref:
            continue
        if is_inline_group(item):
            text_parts, provs = _collect_inline_descendants(ref, by_ref, skip_refs)
            # Re-index prov order so the resulting :Provenance nodes are 0..N-1
            # contiguous instead of carrying each child's individual indices.
            for idx, prov in enumerate(provs):
                prov["order"] = idx
            inline_meta[ref] = {
                "text": " ".join(text_parts),
                "provs": provs,
            }
        elif is_picture(item):
            _collect_descendants(ref, by_ref, skip_refs)

    return skip_refs, inline_meta


def _collect_descendants(
    root_ref: str,
    by_ref: dict[str, dict[str, Any]],
    skip_refs: set[str],
) -> None:
    """DFS `root_ref`'s subtree and add every descendant to `skip_refs`.

    Used for picture children — we just want them dropped, not aggregated.
    """

    def walk(ref: str) -> None:
        item = by_ref.get(ref)
        if item is None:
            return
        for ch in item.get("children") or []:
            child_ref = ch.get("$ref") or ch.get("cref")
            if not child_ref or child_ref in skip_refs:
                continue
            skip_refs.add(child_ref)
            walk(child_ref)

    walk(root_ref)


def _collect_inline_descendants(
    group_ref: str,
    by_ref: dict[str, dict[str, Any]],
    skip_refs: set[str],
) -> tuple[list[str], list[dict[str, Any]]]:
    """DFS an inline group's subtree, returning its text parts and provs in
    document order. `skip_refs` is mutated with every visited descendant."""
    text_parts: list[str] = []
    provs: list[dict[str, Any]] = []

    def walk(ref: str) -> None:
        item = by_ref.get(ref)
        if item is None:
            return
        for ch in item.get("children") or []:
            child_ref = ch.get("$ref") or ch.get("cref")
            if not child_ref or child_ref in skip_refs:
                continue
            skip_refs.add(child_ref)
            child = by_ref.get(child_ref)
            if child is None:
                continue
            if is_inline_group(child):
                walk(child_ref)
                continue
            text = child.get("text") or ""
            if text:
                text_parts.append(text)
            provs.extend(iter_provs(child))

    walk(group_ref)
    return text_parts, provs


def iter_pages(doc_data: dict[str, Any]) -> Iterator[dict[str, Any]]:
    """Yield page dicts with `page_no`, `width`, `height` from the `pages` map."""
    for page_no_str, page_obj in (doc_data.get("pages") or {}).items():
        try:
            page_no = int(page_no_str)
        except (TypeError, ValueError):
            continue
        size = (page_obj or {}).get("size") or {}
        yield {
            "page_no": page_no,
            "width": size.get("width"),
            "height": size.get("height"),
        }
