# This file is part of OpenCV project.
# It is subject to the license terms in the LICENSE file found in the top-level directory
# of this distribution and at http://opencv.org/license.html.
# Copyright (C) 2026, BigVision LLC, all rights reserved.
# Third party copyrights are property of their respective owners.

"""Doxygen XML -> Markdown primitives for the API-reference stubs."""
from __future__ import annotations
import copy as _copy
import pathlib, re, os as _os, shutil as _shutil, textwrap as _textwrap
from .state import *


def _wrap_emphasis(inner: str, delim: str) -> str:
    """Wrap `inner` in a `*`/`**` run, hoisting any leading/trailing whitespace
    outside the markers. CommonMark needs the delimiters to hug the text
    (`**x**`, not `** x **`), but Doxygen keeps the spaces inside `<b> … </b>`."""
    stripped = inner.strip()
    if not stripped:
        return inner
    lead = inner[:len(inner) - len(inner.lstrip())]
    trail = inner[len(inner.rstrip()):]
    return f"{lead}{delim}{stripped}{delim}{trail}"

_AMS_BLOCK_ENVS = frozenset((
    "align", "align*", "alignat", "alignat*", "flalign", "flalign*",
    "gather", "gather*", "multline", "multline*",
    "equation", "equation*", "eqnarray", "eqnarray*",
))


def _render_formula(raw: str) -> str:
    s = (raw or "").strip()
    if s.startswith("\\[") and s.endswith("\\]"):
        inner = s[2:-2].strip()
        m = re.match(r"^\\begin\{([a-zA-Z]+\*?)\}", inner)
        if (m and m.group(1) in _AMS_BLOCK_ENVS
                and inner.rstrip().endswith("\\end{%s}" % m.group(1))):
            return f"\n\n{inner}\n\n"
        return f"\n\n$$\n{inner}\n$$\n\n"
    return s


def _render_image(node) -> str:
    if node.get("type") != "html":
        return ""
    name = (node.get("name") or "").strip()
    caption = "".join(node.itertext()).strip()
    hit = _IMAGE_INDEX.get(name)
    if not hit:
        return ""
    return f"![{caption}](/{hit})"


def _itertext(el) -> str:
    if el is None:
        return ""
    parts: list[str] = []

    def _walk(node) -> None:
        if node.tag == "formula":
            parts.append(_render_formula(node.text or ""))
        elif node.tag == "image":
            parts.append(_render_image(node))   # caption text not recursed
        else:
            if node.text:
                parts.append(node.text)
            for child in node:
                _walk(child)
                if child.tail:
                    parts.append(child.tail)

    _walk(el)
    return "".join(parts).strip()


# memberdef@kind -> section title; order mirrors Doxygen group page.
_MEMBERDEF_SECTIONS = (
    ("typedef",  "Typedefs"),
    ("enum",     "Enumerations"),
    ("function", "Functions"),
    ("variable", "Variables"),
    ("define",   "Macros"),
)


def _read_class_brief(refid: str, xml_dir: pathlib.Path,
                      _cache: dict = {}) -> str:
    """Read brief description from a class/struct compound XML; cached."""
    if refid in _cache:
        return _cache[refid]
    import xml.etree.ElementTree as _ET
    xml_path = xml_dir / f"{refid}.xml"
    brief = ""
    if xml_path.is_file():
        try:
            ccd = _ET.parse(xml_path).getroot().find("compounddef")
            if ccd is not None:
                brief = _itertext(ccd.find("briefdescription"))
        except _ET.ParseError:
            pass
    _cache[refid] = brief
    return brief


def _member_template(md) -> str:
    """`template<...>` prefix for a memberdef."""
    tpl = md.find("templateparamlist")
    if tpl is None:
        return ""
    parts = []
    for p in tpl.findall("param"):
        t = _itertext(p.find("type"))
        d = (p.findtext("declname") or "").strip()
        parts.append(f"{t} {d}".strip() if d else t)
    return f"template<{', '.join(parts)}>" if parts else ""


def _member_detail_parts(md):
    de = md.find("detaileddescription")
    if de is None:
        return "", [], ""
    params, returns = [], ""
    for para in de.findall("para"):
        for pl in para.findall("parameterlist"):
            if pl.get("kind") in ("param", "templateparam"):
                for it in pl.findall("parameteritem"):
                    nm = ", ".join(
                        t for t in (_itertext(n) for n in
                                    it.findall(".//parametername")) if t)
                    # Block-aware: a description carrying an <itemizedlist>
                    # (e.g. calibration `flags`) keeps its bullets as real
                    # Markdown instead of collapsing into a run-on paragraph.
                    d = _doxygen_desc_to_md(it.find("parameterdescription"))
                    if nm:
                        params.append((nm, d))
        for ss in para.findall("simplesect"):
            if ss.get("kind") == "return":
                returns = _itertext(ss)
    # Prune the param/return chrome (rendered separately) then convert the rest
    # with full block support so lists and notes survive. Preserve tail text when
    # removing elements so descriptions after parameterlist are not lost.
    pruned = _copy.deepcopy(de)
    for para in pruned.findall("para"):
        for child in list(para):
            if child.tag == "parameterlist":
                # Preserve tail text (text after the element) by moving it to previous sibling
                if child.tail:
                    prev_idx = list(para).index(child) - 1
                    if prev_idx >= 0:
                        prev_sibling = para[prev_idx]
                        prev_sibling.tail = (prev_sibling.tail or "") + child.tail
                    else:
                        # No previous sibling, prepend to para text
                        para.text = (para.text or "") + child.tail
                para.remove(child)
            elif (child.tag == "simplesect"
                  and child.get("kind") in ("param", "templateparam", "return")):
                # Same tail preservation for simplesect
                if child.tail:
                    prev_idx = list(para).index(child) - 1
                    if prev_idx >= 0:
                        prev_sibling = para[prev_idx]
                        prev_sibling.tail = (prev_sibling.tail or "") + child.tail
                    else:
                        para.text = (para.text or "") + child.tail
                para.remove(child)
    return _doxygen_desc_to_md(pruned), params, returns


# I/O proxy types as a variable type = Doxygen phantom; drop it.
_PHANTOM_VAR_TYPE_RE = re.compile(
    r"\b(?:Input|Output|InputOutput)Array(?:OfArrays)?\b")


def _is_phantom_variable(member: dict) -> bool:
    return (member.get("kind") == "variable"
            and bool(_PHANTOM_VAR_TYPE_RE.search(member.get("type") or "")))


def _parse_member_sections(cd) -> dict[str, list[dict]]:
    """Parse compounddef memberdefs into `{section_title: [member dict]}`."""
    sections: dict[str, list[dict]] = {}
    _seen_member_ids: set[str] = set()
    for sd in cd.findall("sectiondef"):
        # Doxygen `@name` member-group title; carried onto each member.
        sd_header = (sd.findtext("header") or "").strip()
        for md in sd.findall("memberdef"):
            kind = md.get("kind", "")
            section_title = dict(_MEMBERDEF_SECTIONS).get(kind)
            if not section_title:
                continue
            _mid = md.get("id", "")
            if _mid and _mid in _seen_member_ids:
                continue
            _seen_member_ids.add(_mid)
            qualified = (md.findtext("qualifiedname") or "").strip()
            if not qualified:
                qualified = (md.findtext("name") or "").strip()
            # Merge <array> suffix into <type> or breathe drops it.
            def _param_type(p) -> str:
                t = _itertext(p.find("type"))
                arr = (p.findtext("array") or "").strip()
                return (t + arr) if arr else t
            param_types = [_param_type(p) for p in md.findall("param")]
            # (type, name, default) per parameter — drives the multi-line,
            # column-aligned function signature in the detail card.
            def _param_sig(p) -> tuple:
                return (_param_type(p),
                        (p.findtext("declname") or "").strip(),
                        _itertext(p.find("defval")))
            params_sig = [_param_sig(p) for p in md.findall("param")]
            # Function-like macro params carry only a <defname> (no type/declname).
            macro_params = ([(p.findtext("defname") or "").strip()
                             for p in md.findall("param")] if kind == "define"
                            else [])
            # `= value` for a variable; the macro body for a define.
            initializer = _itertext(md.find("initializer"))
            enum_values = []
            is_strong = md.get("strong", "no") == "yes"
            if kind == "enum":
                for ev in md.findall("enumvalue"):
                    enum_values.append({
                        "name":        (ev.findtext("name") or "").strip(),
                        "initializer": (ev.findtext("initializer") or "").strip(),
                        # Per-enumerator brief for the detail block.
                        "brief":       _enum_value_desc(ev),
                    })
            _dtl, _params, _returns = _member_detail_parts(md)
            _loc = md.find("location")
            _include_file = _normalize_include(
                (_loc.get("file") if _loc is not None else "") or "")
            member = {
                "id":          md.get("id", ""),
                "kind":        kind,
                "name":        (md.findtext("name") or "").strip(),
                "qualified":   qualified,
                "definition":  (md.findtext("definition") or "").strip(),
                "virt":        md.get("virt", "non-virtual"),
                "type":        _itertext(md.find("type")),
                "args":        (md.findtext("argsstring") or "").strip(),
                "param_types": param_types,
                "params_sig":  params_sig,
                "macro_params": macro_params,
                "initializer": initializer,
                "brief":       _itertext(md.find("briefdescription")),
                "enum_values": enum_values,
                "strong":      is_strong,
                "static":      md.get("static") == "yes",
                "inline":      md.get("inline") == "yes",
                "const":       md.get("const") == "yes",
                "template":    _member_template(md),
                "include_file": _include_file,
                "section_header": sd_header,
                "detailed":    _dtl,
                "params":      _params,
                "returns":     _returns,
            }
            if _is_phantom_variable(member):
                continue
            sections.setdefault(section_title, []).append(member)
    return sections


def _build_api_hierarchy(refid: str, xml_dir: pathlib.Path,
                         _seen: set | None = None) -> dict | None:
    """Walk a group XML's <innergroup> children recursively."""
    import xml.etree.ElementTree as _ET
    _seen = _seen if _seen is not None else set()
    if refid in _seen:
        return None
    _seen.add(refid)
    xml_path = xml_dir / f"{refid}.xml"
    if not xml_path.is_file():
        return None
    try:
        root = _ET.parse(xml_path).getroot()
    except _ET.ParseError:
        return None
    cd = root.find("compounddef")
    if cd is None:
        return None
    name = (cd.findtext("compoundname") or "").strip()
    title = (cd.findtext("title") or name).strip()
    brief_el = cd.find("briefdescription")
    brief = _doxygen_desc_to_md(brief_el).strip() if brief_el is not None else ""
    detailed_el = cd.find("detaileddescription")
    detailed = _doxygen_desc_to_md(detailed_el) if detailed_el is not None else ""
    # Inner classes (public only). One read per class's XML for its brief.
    # `qualified` is what `{doxygenclass}` needs (e.g. cv::ocl::Context); the
    # innerclass element's text already carries that, but normalize spaces.
    innerclasses = []
    for ic in cd.findall("innerclass"):
        if ic.get("prot") != "public":
            continue
        ic_refid = ic.get("refid", "")
        qualified = " ".join((ic.text or "").split())
        innerclasses.append({
            "refid": ic_refid,
            "name": qualified,
            "qualified": qualified,
            "kind": "struct" if ic_refid.startswith("struct") else "class",
            "brief": _read_class_brief(ic_refid, xml_dir),
        })
    sections = _parse_member_sections(cd)
    children = []
    for ig in cd.findall("innergroup"):
        child = _build_api_hierarchy(ig.get("refid"), xml_dir, _seen)
        if child is not None:
            children.append(child)
    return {"name": name, "title": title, "brief": brief, "detailed": detailed,
            "innerclasses": innerclasses, "sections": sections,
            "children": children}


def _type_to_md(type_elem) -> str:
    """Render <type> XML as Markdown; turns <ref> children into links."""
    if type_elem is None:
        return ""
    out = type_elem.text or ""
    for child in type_elem:
        if child.tag == "ref":
            word = (child.text or "").strip()
            refid = child.get("refid", "")
            kindref = child.get("kindref", "")
            if kindref == "compound" and refid in _ANCHOR_TO_DOC:
                fn = _ANCHOR_TO_DOC[refid].split("/")[-1]
                out += f"[{word}]({fn}.md)"
            elif refid:
                out += f"[{word}](#{refid})"
            else:
                out += word
        out += child.tail or ""
    return out.strip()


def _doxygen_desc_to_md(el, h_level: int = 3) -> str:
    """Convert a Doxygen <detaileddescription> element to Markdown."""
    if el is None:
        return ""

    def _hl_text(hl_node) -> str:
        parts = []
        if hl_node.text:
            parts.append(hl_node.text)
        for child in hl_node:
            if child.tag == "sp":
                parts.append(" ")
            else:
                parts.append("".join(child.itertext()))
            if child.tail:
                parts.append(child.tail)
        return "".join(parts)

    # Doxygen <highlight class="…"> → Pygments token class, so the
    # rendered <pre> picks up the existing `.highlight pre .k/.n/.kt/.s/.c`
    # CSS styling and our `code > a { color: inherit }` rule won't dim the
    # link colour.
    _HL_PYG_CLASS = {
        "keyword": "k", "keywordtype": "kt", "keywordflow": "k",
        "preprocessor": "cp", "comment": "c", "comment-multiline": "cm",
        "stringliteral": "s", "charliteral": "sc",
    }
    # Identifier-only token regex; used to linkify `InputArray`,
    # `OutputArray`, `Mat`, `Scalar`, `DFT_INVERSE`, … left as plain
    # text by Doxygen (no `<ref>` on them).
    _IDENT_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
    # `#include "opencv2/…"` / `#include <opencv2/…>` regex. Doxygen
    # wraps the WHOLE include directive (keyword + quoted/angled path)
    # in one `<highlight class="preprocessor">` span — no separate
    # token for the path — so the per-identifier linkifier above can't
    # see the path. Match the include line and wrap just the path in
    # an `<a>` to its local Doxygen file page (`_FILE_URL`).
    _INCLUDE_PATH_RE = re.compile(
        r'(#include\s*)(["<])([A-Za-z0-9_./+\-]+\.[A-Za-z0-9]+)([">])'
    )

    def _programlisting(node) -> str:
        """Emit `<programlisting>` as a raw HTML `<pre>` block with
        per-token `<a>` anchors. The user's spec: code listings must
        be CLICKABLE — every recognised identifier (class/typedef/enum
        member or `<ref>`-marked token) becomes a link to its local
        Sphinx target. Falls back to plain text for unknown tokens, so
        keywords/numbers/operators stay un-linked but still picked up
        by the Pygments-style span classes for colouring."""
        from html import escape as _esc
        out = ['<div class="highlight-cpp notranslate"><div class="highlight"><pre>']

        def _linkify_plain(text: str) -> str:
            """Wrap identifier runs that resolve via `_local_url`
            with an `<a>`; everything else stays HTML-escaped text."""
            parts: list[str] = []
            last = 0
            for m in _IDENT_RE.finditer(text):
                if m.start() > last:
                    parts.append(_esc(text[last:m.start()]))
                tok = m.group(0)
                url = _LOCAL_CLASS_URL.get(tok) or _LOCAL_TYPEDEF_URL.get(tok)
                if url:
                    parts.append(
                        f'<a class="reference internal" href="{url}">'
                        f'<span class="n">{_esc(tok)}</span></a>')
                else:
                    parts.append(_esc(tok))
                last = m.end()
            parts.append(_esc(text[last:]))
            return "".join(parts)

        def _linkify_include_path(escaped: str) -> str:
            """Find `#include "opencv2/…"` patterns in already-escaped
            text and wrap the path in an `<a>` to its Doxygen file
            page. Operates on POST-escape text (where `"` is `&quot;`
            and `<>` are `&lt;`/`&gt;`)."""
            def _sub(m: re.Match) -> str:
                kw, openq, path, closeq = m.groups()
                file_url = _FILE_URL.get(path)
                if not file_url:
                    return m.group(0)
                href = f"../../../doc/doxygen/html/{file_url}"
                return (f'{kw}{openq}'
                        f'<a class="reference external opencv-include-link" '
                        f'href="{href}">{path}</a>{closeq}')
            # The escape map converts `"` → `&quot;`, `<` → `&lt;`,
            # `>` → `&gt;`; rewrite the regex once for that form.
            def _re_escaped() -> "re.Pattern":
                return re.compile(
                    r'(#include\s*)(&quot;|&lt;)'
                    r'([A-Za-z0-9_./+\-]+\.[A-Za-z0-9]+)'
                    r'(&quot;|&gt;)'
                )
            return _re_escaped().sub(_sub, escaped)

        def _emit_highlight(hl) -> str:
            """One `<highlight class="…">` → optionally-wrapped span
            sequence preserving inline `<ref>` link targets."""
            klass = _HL_PYG_CLASS.get(hl.get("class", ""), "")
            segs: list[str] = []
            if hl.text:
                segs.append(_linkify_plain(hl.text))
            for child in hl:
                if child.tag == "sp":
                    segs.append(" ")
                elif child.tag == "ref":
                    inner = "".join(child.itertext())
                    url = _local_url(child.get("refid", ""), inner)
                    if url:
                        segs.append(
                            f'<a class="reference internal" href="{url}">'
                            f'<span class="n">{_esc(inner)}</span></a>')
                    else:
                        segs.append(_linkify_plain(inner))
                else:
                    segs.append(_linkify_plain("".join(child.itertext())))
                if child.tail:
                    segs.append(_linkify_plain(child.tail))
            body = "".join(segs)
            # Preprocessor lines: linkify any `#include "path"` AFTER
            # the per-identifier pass (Doxygen lumps the whole include
            # directive into one `class="preprocessor"` span, so the
            # path can only be found at the assembled-body level).
            if klass == "cp":
                body = _linkify_include_path(body)
            # Wrap in a Pygments-style `<span class="X">` only for token
            # kinds whose colouring would otherwise be lost. The "normal"
            # highlight class is the un-coloured default — emit without
            # the outer span so embedded `<a>` link colours win.
            if klass and body:
                return f'<span class="{klass}">{body}</span>'
            return body

        codelines = node.findall("codeline")
        for i, cl in enumerate(codelines):
            parts = [_emit_highlight(hl) for hl in cl.findall("highlight")]
            line = "".join(parts)
            # Empty codeline → emit `<span></span>` placeholder so the
            # blank line doesn't terminate the surrounding raw-HTML
            # block under CommonMark rule 7.
            out.append(line if line else "<span></span>")
        out.append('</pre></div></div>')
        # Wrap the whole thing in a `\n` so MyST parses it as a raw
        # HTML block (CommonMark type 6) — the leading line is `<div…>`
        # which qualifies. Trailing blank line ends the block.
        return "\n".join(out) + "\n"

    def _ref_link(refid: str, text: str) -> str:
        """Inline `<ref>` → blue link to a LOCAL Sphinx target.
        The user's spec for cross-references: "blue text, no grey
        chip, redirect to local pages, no off-site bounce". So:
          - LOCAL URLs only via `_local_url(refid, text)`; when no
            local target exists we drop the link (return plain text)
            instead of routing the reader to docs.opencv.org.
          - emitted as RAW HTML (`<a class="reference internal" …>`)
            rather than markdown `[text](url)`. Markdown link syntax
            in MyST/Sphinx with a `#fragment` URL is interpreted as a
            pending domain xref — when the fragment doesn't match a
            registered domain target the result is `<span class="xref
            myst">text</span>` (unresolved) plus a `#` prefix on the
            already-`#` href. Raw HTML bypasses the xref resolver so
            the anchor stays a plain in-page link.
          - no backticks → no `<code>` grey chip and no 500-weight
            on the text.
        """
        if not text:
            return ""
        url = _local_url(refid, text) if refid else None
        if not url:
            return text
        from html import escape as _esc_rl
        return (f'<a class="reference internal" href="{url}">'
                f'{_esc_rl(text)}</a>')

    def _local_url(refid: str, name: str) -> str | None:
        """Resolve a Doxygen `<ref>` to a Sphinx-local URL.
        Layered lookup:
          1. Class/struct short-name → curated map (`Mat`, `InputArray`,…)
             from `_LOCAL_CLASS_URL`/`_LOCAL_TYPEDEF_URL`.
          2. Class/struct member refid (`classcv_1_1Mat_1a<hex>`) →
             class page + slugified member anchor.
          3. Group-anchored function refid (`group__core__array_1ga<hex>`)
             → same-page slugified anchor (the function's detail block
             emits `({refid})=` which MyST turns into `<span id="…">`).
          4. Bare class/struct compound refid → class page.
        Returns None when nothing matches — caller renders plain text."""
        if not refid:
            return None
        # Try both the full name and its short form (after the last
        # `::`). The typedef/class maps key on the short name only
        # (e.g. `InputArray`, not `cv::InputArray`), so a `<ref>` whose
        # text says `cv::InputArray` would otherwise miss this lookup
        # and fall through to the function-slug branch below — minting
        # a non-existent `#cv-inputarray` anchor instead of the real
        # typedef target `core_basic.html#inputarray`.
        short_name = name.rsplit("::", 1)[-1] if name else ""
        direct = (_LOCAL_CLASS_URL.get(name) or _LOCAL_TYPEDEF_URL.get(name)
                  or _LOCAL_CLASS_URL.get(short_name)
                  or _LOCAL_TYPEDEF_URL.get(short_name))
        if direct:
            return direct
        # Class-member refid (long hex suffix).
        cm = re.match(
            r"^((?:class|struct)cv_1_1[A-Za-z0-9_]+?)_1"
            r"([a-z]{1,3}[0-9a-f]{20,})$", refid)
        if cm:
            page = cm.group(1)
            slug = re.sub(r"_+", "-", refid).lower()
            return f"{page}.html#{slug}"
        # Group-anchored member on the current group page.
        #
        # Doxygen uses two refid suffixes for group members:
        #   * `_1ga<hex>`  — function in a group → anchor is the
        #                    `_func_slug(name)` (cv-name) emitted by
        #                    `_render_core_basic_func`.
        #   * `_1gga<hex>` — enum VALUE in a group → anchor is the
        #                    C++ v4 id (`_CPPv4N…E`) emitted by the
        #                    per-value `<span id>` in the enum
        #                    detail table. The enum-type's parent
        #                    name is captured via `_CV_SYMBOL_URL`
        #                    elsewhere; here we use the value name
        #                    + the parent enum name when available
        #                    to mint the same id.
        m = re.search(r"^group__(?P<grp>[A-Za-z0-9_]+?)_1(?P<kind>gga|ga)(?P<hex>[0-9a-f]+)$", refid)
        if m:
            kind = m.group("kind")
            if kind == "ga":  # function
                short = (name.rsplit("::", 1)[-1] if name else "")
                short = short.split("(", 1)[0].strip()
                if not short:
                    return None
                page_stem = m.group("grp").replace("__", "_")
                return f"{page_stem}.html#{short.lower()}"
            # Enum VALUE `gga<enumhash>a<valuehash>`: group pages carry no
            # per-value anchor, so link to the enum TYPE's doc (refid ga<enumhash>).
            hexv = m.group("hex")
            if len(hexv) == 65 and hexv[32] == "a":
                return _ENUM_REF_URL.get(
                    f"group__{m.group('grp')}_1ga{hexv[:32]}")
            return None
        # Bare class/struct compound page.
        if refid.startswith(("classcv_1_1", "structcv_1_1")):
            return f"{refid}.html"
        return None
    _formula_md = _render_formula

    _BLOCK_TAGS = {"orderedlist", "itemizedlist", "programlisting", "simplesect",
                   "table", "xrefsect"}

    # Map missing 'see' and 'sa' Doxygen sections to MyST 'seealso' admonitions to fix unboxed reference rendering.
    _ADMON_BY_KIND = {"note": "note", "warning": "warning",
                      "attention": "warning", "remark": "note",
                      "see": "seealso", "sa": "seealso"}

    def _emit_block(sub, result: list, level: int) -> None:
        t = sub.tag
        if t == "programlisting":
            result.append(_programlisting(sub))
        elif t == "orderedlist":
            for i, item in enumerate(sub.findall("listitem"), 1):
                result.append(f"{i}. {_listitem_text(item)}")
        elif t == "itemizedlist":
            for item in sub.findall("listitem"):
                result.append(f"- {_listitem_text(item)}")
        elif t == "simplesect":
            kind = sub.get("kind", "")
            admon = _ADMON_BY_KIND.get(kind)
            body = "\n\n".join(_blocks(sub, level))
            if admon:
                result.append(f":::{{{admon}}}\n{body}\n:::")
            elif body:
                result.append(body)
        elif t == "xrefsect":
            # @todo/@bug/@deprecated -> a titled admonition box (like Doxygen's).
            title = (sub.findtext("xreftitle") or "Note").strip()
            desc = sub.find("xrefdescription")
            body = "\n\n".join(_blocks(desc, level)) if desc is not None else ""
            if body:
                result.append(
                    f":::{{admonition}} {title}\n:class: {title.lower()}\n\n{body}\n:::")
        elif t == "table":
            rows = sub.findall("row")
            if not rows:
                return
            md_rows = []
            for row in rows:
                cells = [" ".join(_blocks(e, level)).replace("|", "\\|").replace("\n", " ").strip()
                         for e in row.findall("entry")]
                md_rows.append("| " + " | ".join(cells) + " |")
            has_header = (rows[0].find("entry") is not None
                          and rows[0].find("entry").get("thead") == "yes")
            if has_header and md_rows:
                cols = len(rows[0].findall("entry"))
                table_lines = [md_rows[0], "| " + " | ".join(["----"] * cols) + " |"] + md_rows[1:]
            else:
                table_lines = md_rows
            result.append("\n".join(table_lines))

    def _listitem_text(item, indent: int = 0) -> str:
        """Render one <listitem>'s content, recursively including nested
        lists. Without this, sub-bullets (e.g. the Modern-Robotics
        link / det(R)=1 properties / Slerp item under the camera-calib
        Note) were silently dropped — `_inline` breaks at the first
        block tag so the nested `<itemizedlist>` inside the parent
        `<para>` was never recursed into. Doxygen puts the nested list
        BOTH inside `<para>` (the common case) AND occasionally as a
        direct child of `<listitem>`, so we check both spots."""
        first = ""
        extras: list[str] = []
        pad = " " * (indent + 2)

        def _emit_sublist(nl) -> None:
            if nl.tag == "itemizedlist":
                for sub in nl.findall("listitem"):
                    extras.append(
                        f"{pad}- {_listitem_text(sub, indent + 2)}")
            elif nl.tag == "orderedlist":
                for i, sub in enumerate(nl.findall("listitem"), 1):
                    extras.append(
                        f"{pad}{i}. {_listitem_text(sub, indent + 2)}")

        for child in item:
            if child.tag == "para":
                # Inline text up to (but not including) the first block tag.
                txt = _inline(child).strip()
                if txt:
                    if not first:
                        first = txt
                    else:
                        extras.append(pad + txt)
                # Nested lists living inside the <para> (Doxygen's
                # typical layout for the OpenCV @note bullets).
                for inner in child:
                    if inner.tag in ("itemizedlist", "orderedlist"):
                        _emit_sublist(inner)
            elif child.tag in ("itemizedlist", "orderedlist"):
                _emit_sublist(child)
        if extras:
            return first + "\n" + "\n".join(extras) if first else "\n".join(extras)
        return first

    def _inline(node) -> str:
        parts = []
        if node.text:
            parts.append(node.text)
        for child in node:
            t = child.tag
            if t in _BLOCK_TAGS:
                break
            inner = "".join(child.itertext())
            if t == "ulink":
                url = child.get("url", "")
                parts.append(f"[{inner}]({url})" if url else inner)
            elif t == "image":
                parts.append(_render_image(child))
            elif t == "ref":
                parts.append(_ref_link(child.get("refid", ""), inner))
            elif t == "computeroutput":
                parts.append(f"`{inner}`" if inner else "")
            elif t == "emphasis":
                parts.append(_wrap_emphasis(inner, "*") if inner else "")
            elif t in ("bold", "strong"):
                parts.append(_wrap_emphasis(inner, "**") if inner else "")
            elif t == "formula":
                parts.append(_formula_md(inner))
            elif t == "sp":
                parts.append(" ")
            elif t == "linebreak":
                parts.append("\n")
            else:
                parts.append(inner)
            if child.tail:
                parts.append(child.tail)
        return "".join(parts)

    def _blocks(node, level: int) -> list[str]:
        result = []
        children = list(node)
        i = 0
        while i < len(children):
            child = children[i]
            t = child.tag
            if t == "title":
                i += 1
                continue
            elif t == "simplesect":
                # Merge consecutive simplesects of the same kind into one admonition.
                kind = child.get("kind", "")
                admon = _ADMON_BY_KIND.get(kind)
                bodies = []
                while (i < len(children) and children[i].tag == "simplesect"
                       and children[i].get("kind", "") == kind):
                    bodies.append("\n\n".join(_blocks(children[i], level)))
                    i += 1
                body = "\n\n".join(b for b in bodies if b)
                if admon and body:
                    result.append(f":::{{{admon}}}\n{body}\n:::")
                elif body:
                    result.append(body)
                continue
            elif t == "para":
                pending: list[str] = []
                if child.text:
                    pending.append(child.text)
                for sub in child:
                    if sub.tag in _BLOCK_TAGS:
                        text = "".join(pending).strip()
                        if text:
                            result.append(text)
                        pending = []
                        _emit_block(sub, result, level)
                        if sub.tail and sub.tail.strip():
                            pending.append(sub.tail)
                    else:
                        inner = "".join(sub.itertext())
                        st = sub.tag
                        if st == "ulink":
                            url = sub.get("url", "")
                            pending.append(f"[{inner}]({url})" if url else inner)
                        elif st == "image":
                            pending.append(_render_image(sub))
                        elif st == "ref":
                            pending.append(_ref_link(sub.get("refid", ""), inner))
                        elif st == "computeroutput":
                            pending.append(f"`{inner}`" if inner else "")
                        elif st == "emphasis":
                            pending.append(_wrap_emphasis(inner, "*") if inner else "")
                        elif st in ("bold", "strong"):
                            pending.append(_wrap_emphasis(inner, "**") if inner else "")
                        elif st == "formula":
                            pending.append(_formula_md(inner))
                        elif st == "sp":
                            pending.append(" ")
                        elif st == "linebreak":
                            pending.append("\n")
                        else:
                            pending.append(inner)
                        if sub.tail:
                            pending.append(sub.tail)
                text = "".join(pending).strip()
                if text:
                    result.append(text)
            elif t in ("sect1", "sect2", "sect3"):
                title_text = child.findtext("title") or ""
                if title_text:
                    result.append(f"{'#' * level} {title_text}")
                    result.extend(_blocks(child, level + 1))
                else:
                    result.extend(_blocks(child, level))
            elif t in _BLOCK_TAGS:
                _emit_block(child, result, level)
            i += 1
        return result

    # Merge consecutive same-kind admonitions (e.g. two note paras → one box).
    raw = [b for b in _blocks(el, h_level) if b.strip()]
    merged: list[str] = []
    for block in raw:
        if merged and block.startswith(":::") and merged[-1].startswith(":::"):
            prev_kind = merged[-1].split("\n", 1)[0]
            cur_kind = block.split("\n", 1)[0]
            if prev_kind == cur_kind:
                inner_prev = merged[-1][len(prev_kind)+1:-3].strip()
                inner_cur = block[len(cur_kind)+1:-3].strip()
                merged[-1] = f"{prev_kind}\n{inner_prev}\n\n{inner_cur}\n:::"
                continue
        merged.append(block)
    return "\n\n".join(merged)


def _normalize_include(path: str) -> str:
    p = (path or "").replace("\\", "/").strip()
    marker = "/include/"
    i = p.rfind(marker)
    return p[i + len(marker):] if i >= 0 else p


def _enum_value_desc(ev) -> str:
    if ev is None:
        return ""
    parts = [_doxygen_desc_to_md(ev.find(t)).strip()
             for t in ("briefdescription", "detaileddescription")]
    return "\n\n".join(p for p in parts if p)


def _md_escape_cell(text: str) -> str:
    """Make `text` safe for a single Markdown table cell."""
    return (text or "").replace("\n", " ").replace("\r", " ") \
                       .replace("|", "\\|").strip()


# memberdef@kind -> per-member breathe directive.
_MEMBER_DIRECTIVE = {
    "enum":     "doxygenenum",
    "function": "doxygenfunction",
    "typedef":  "doxygentypedef",
    "variable": "doxygenvariable",
    "define":   "doxygendefine",
}
# section title -> detail-block header.
_MEMBER_DETAIL_SECTION = {
    "Typedefs":     "Typedef Documentation",
    "Enumerations": "Enumeration Type Documentation",
    "Functions":    "Function Documentation",
    "Variables":    "Variable Documentation",
    "Macros":       "Macro Definition Documentation",
}


def _sphinx_cpp_v4_id(qualified_name: str) -> str:
    """Sphinx C++ v4 anchor id; mangling must match Sphinx's own."""
    parts = qualified_name.split("::")
    mangled = "".join(f"{len(p)}{p}" for p in parts)
    return f"_CPPv4N{mangled}E"


def _enum_synopsis_html(m: dict, strip_scope: str = "") -> list[str]:
    """Render an enum as an HTML block with clickable names.

    Span classes mirror Pygments' cpp-lexer output. `strip_scope` drops the
    redundant prefix from displayed names but never from the mangled id."""
    import html as _html
    qualified = m.get("qualified") or m["name"]
    is_strong = bool(m.get("strong"))
    keyword = "enum class" if is_strong else "enum"
    if is_strong:
        prefix = qualified + "::"
    elif "::" in qualified:
        prefix = qualified.rsplit("::", 1)[0] + "::"
    else:
        prefix = ""
    display_enum = qualified
    if strip_scope and qualified.startswith(strip_scope + "::"):
        display_enum = qualified[len(strip_scope) + 2:]
    display_prefix = prefix
    if strip_scope and prefix.startswith(strip_scope + "::"):
        display_prefix = prefix[len(strip_scope) + 2:]

    enum_id = _sphinx_cpp_v4_id(qualified)
    out = [
        '<div class="highlight-cpp notranslate opencv-enum-synopsis">'
        '<div class="highlight"><pre>'
    ]
    # href only, no id; anchors live in the detail loop.
    enum_link = (
        f'<a class="opencv-enum-link" href="#{enum_id}">'
        f'<span class="n">{_html.escape(display_enum)}</span></a>'
    )
    out.append(
        f'<span class="k">{_html.escape(keyword)}</span> '
        f'{enum_link} <span class="p">{{</span>'
    )
    vals = m.get("enum_values", []) or []
    for i, v in enumerate(vals):
        comma = '<span class="p">,</span>' if i < len(vals) - 1 else ""
        val_id = _sphinx_cpp_v4_id(f"{qualified}::{v['name']}")
        val_link = (
            f'<a class="opencv-enum-link" href="#{val_id}">'
            f'<span class="n">{_html.escape(v["name"])}</span></a>'
        )
        init_html = (" " + _html.escape(v["initializer"])) if v["initializer"] else ""
        prefix_html = ""
        if display_prefix:
            stripped = display_prefix.rstrip(":")
            prefix_html = (
                f'<span class="n">{_html.escape(stripped)}</span>'
                f'<span class="o">::</span>'
            )
        out.append(f"    {prefix_html}{val_link}{init_html}{comma}")
    out.append('<span class="p">}</span></pre></div></div>')
    return out


def _enum_synopsis_lines(m: dict, strip_scope: str = "") -> list[str]:
    """Render an enum as a Doxygen-style `enum {…}` code synopsis."""
    qualified = m.get("qualified") or m["name"]
    is_strong = bool(m.get("strong"))
    keyword = "enum class" if is_strong else "enum"
    if is_strong:
        prefix = qualified + "::"
    elif "::" in qualified:
        prefix = qualified.rsplit("::", 1)[0] + "::"
    else:
        prefix = ""
    enum_label = qualified
    if strip_scope and qualified.startswith(strip_scope + "::"):
        enum_label = qualified[len(strip_scope) + 2:]
    if strip_scope and prefix.startswith(strip_scope + "::"):
        prefix = prefix[len(strip_scope) + 2:]
    out = [f"{keyword} {enum_label} {{"]
    vals = m.get("enum_values", []) or []
    for i, v in enumerate(vals):
        comma = "," if i < len(vals) - 1 else ""
        init = (" " + v["initializer"]) if v["initializer"] else ""
        out.append(f"    {prefix}{v['name']}{init}{comma}")
    out.append("}")
    return out


def _function_signature(member: dict) -> str:
    """`(types)` disambiguator for `{doxygenfunction}`; trailing `const` needed for const methods."""
    types = ", ".join((t or "").strip() for t in member.get("param_types", []))
    sig = f"({types})"
    if member.get("const"):
        sig += " const"
    return sig


def _class_page_name(refid: str) -> str:
    """Per-class page filename: the Doxygen refid verbatim."""
    return refid


def _read_class_data(refid: str, xml_dir: pathlib.Path) -> dict | None:
    """Read a class/struct compound XML for the per-class page."""
    import xml.etree.ElementTree as _ET
    xml_path = xml_dir / f"{refid}.xml"
    if not xml_path.is_file():
        return None
    try:
        root = _ET.parse(xml_path).getroot()
    except _ET.ParseError:
        return None
    cd = root.find("compounddef")
    if cd is None:
        return None

    def _param_type(p) -> str:
        t = _itertext(p.find("type"))
        arr = (p.findtext("array") or "").strip()
        return (t + arr) if arr else t

    sections: dict[str, list[dict]] = {}
    for sd in cd.findall("sectiondef"):
        skind = sd.get("kind", "")
        items: list[dict] = []
        for md in sd.findall("memberdef"):
            mkind = md.get("kind", "")
            qualified = (md.findtext("qualifiedname") or "").strip()
            name = (md.findtext("name") or "").strip()
            enum_values = []
            if mkind == "enum":
                for ev in md.findall("enumvalue"):
                    enum_values.append({
                        "name":        (ev.findtext("name") or "").strip(),
                        "initializer": (ev.findtext("initializer") or "").strip(),
                        "brief":       _enum_value_desc(ev),
                    })
            _dtl, _params, _returns = _member_detail_parts(md)
            items.append({
                "id":          md.get("id", ""),
                "kind":        mkind,
                "name":        name,
                "qualified":   qualified or name,
                "type":        _itertext(md.find("type")),
                "args":        (md.findtext("argsstring") or "").strip(),
                "param_types": [_param_type(p) for p in md.findall("param")],
                "params_sig":  [(_param_type(p),
                                 (p.findtext("declname") or "").strip(),
                                 _itertext(p.find("defval")))
                                for p in md.findall("param")],
                "macro_params": ([(p.findtext("defname") or "").strip()
                                   for p in md.findall("param")]
                                  if mkind == "define" else []),
                "initializer": _itertext(md.find("initializer")),
                "brief":       _itertext(md.find("briefdescription")),
                "static":      md.get("static") == "yes",
                "virt":        md.get("virt", "non-virtual"),
                "const":       md.get("const") == "yes",
                "explicit":    md.get("explicit") == "yes",
                "enum_values": enum_values,
                "strong":      md.get("strong", "no") == "yes",
                "template":    _member_template(md),
                "detailed":    _dtl,
                "params":      _params,
                "returns":     _returns,
                # (text, refid) for every <ref> in the return type, param types
                # and default values — the exact tokens Doxygen hyperlinks in a
                # signature (classes, typedefs, enum values), keyed by refid.
                "sig_refs":    [((r.text or "").strip(), r.get("refid", ""))
                                for el in ([md.find("type")]
                                           + [c for p in md.findall("param")
                                              for c in (p.find("type"),
                                                        p.find("defval"))])
                                if el is not None
                                for r in el.iter("ref")
                                if (r.text or "").strip() and r.get("refid")],
            })
        if items:
            sections[skind] = items

    # Boolean only; rendering delegated to breathe's :no-members:.
    detailed_el = cd.find("detaileddescription")
    has_detailed = bool(detailed_el is not None and any(
        _itertext(p).strip() for p in detailed_el.findall("para")
    ))
    include = _normalize_include(cd.findtext("includes") or "")
    bases = [(b.get("refid"), (b.text or "").strip(), b.get("prot", "public"))
             for b in cd.findall("basecompoundref") if b.get("refid")]
    return {
        "name":     (cd.findtext("compoundname") or "").strip(),
        "brief":    _itertext(cd.find("briefdescription")),
        "detailed": has_detailed,
        "sections": sections,
        "include":  include,
        "bases":    bases,
    }


# Filename -> path for every Doxygen `*graph.svg` (coll/call/caller). Built once
# per html_root: a single tree walk replaces an rglob per class/function, which
# matters now that thousands of function detail blocks each look up a graph.
_GRAPH_SVG_INDEX: dict[str, pathlib.Path] | None = None
_GRAPH_SVG_ROOT: pathlib.Path | None = None


def _graph_svg_index(html_root: pathlib.Path) -> dict[str, pathlib.Path]:
    global _GRAPH_SVG_INDEX, _GRAPH_SVG_ROOT
    if _GRAPH_SVG_INDEX is None or _GRAPH_SVG_ROOT != html_root:
        _GRAPH_SVG_ROOT = html_root
        _GRAPH_SVG_INDEX = {}
        if html_root and html_root.is_dir():
            for p in html_root.rglob("*graph.svg"):
                _GRAPH_SVG_INDEX.setdefault(p.name, p)
    return _GRAPH_SVG_INDEX


def _find_collaboration_svg(refid: str, html_root: pathlib.Path) -> pathlib.Path | None:
    """Locate the legacy Doxygen HTML collaboration SVG for a class."""
    return _graph_svg_index(html_root).get(f"{refid}__coll__graph.svg")


def _find_call_graph_svgs(
        member: dict,
        html_root: pathlib.Path) -> list[tuple[pathlib.Path, str, str]]:
    """Legacy call/caller-graph SVGs for a function member, as (path, intro, alt).

    Bridges the member's XML id to the HTML anchor via `_CALL_GRAPH_ANCHORS`
    (the SVGs are named after the HTML anchor, not the XML memberdef id)."""
    if member.get("kind") != "function":
        return []
    mid = member.get("id", "")
    if "_1" not in mid:
        return []
    compound = mid.rsplit("_1", 1)[0]
    name = member.get("name", "")
    if not (compound and name):
        return []
    anchor = _CALL_GRAPH_ANCHORS.get(
        (compound, name, _norm_args(member.get("args", ""))))
    if not anchor:
        return []
    index = _graph_svg_index(html_root)
    out: list[tuple[pathlib.Path, str, str]] = []
    for suffix, intro, kind in (
        ("cgraph", "Here is the call graph for this function:", "Call"),
        ("icgraph", "Here is the caller graph for this function:", "Caller"),
    ):
        svg = index.get(f"{compound}_{anchor}_{suffix}.svg")
        if svg is not None:
            out.append((svg, intro, f"{kind} graph for {name}"))
    return out


def _svg_make_transparent(text: str) -> str:
    """Light-mode: make only the full-canvas backdrop transparent."""
    return text.replace('fill="white" stroke="transparent"',
                        'fill="none" stroke="transparent"', 1)


def _svg_dark_variant(text: str) -> str:
    """Dark-mode: recolour the light Doxygen SVG into a dark diagram."""
    import re as _re
    text = _svg_make_transparent(text)              # backdrop first; order matters
    for _old in ('stroke="#666666"', 'stroke="black"', 'stroke="#404040"'):
        text = text.replace(_old, 'stroke="#c9d1d9"')
    text = text.replace('fill="#666666"', 'fill="#c9d1d9"')
    def _process_node(m):
        block = m.group(0)
        block = block.replace('fill="white"', 'fill="none"')
        block = block.replace('fill="grey"',  'fill="none"')
        block = block.replace('fill="#999999"', 'fill="#2d333b"')
        block = block.replace('fill="#bfbfbf"', 'fill="#2d333b"')
        # Non-clickable leaf boxes (std headers) are light grey #e0e0e0 — make
        # them the same darker grey as the current-file box, white text below.
        block = block.replace('fill="#e0e0e0"', 'fill="#2d333b"')
        block = block.replace('<text ', '<text fill="#ffffff" ')
        return block
    text = _re.sub(r'<g[^>]*class="node"[^>]*>.*?</g>',
                   _process_node, text, flags=_re.DOTALL)
    text = text.replace('fill="grey"',     'fill="none"')
    text = text.replace('fill="#999999"',  'fill="#2d333b"')
    text = text.replace('fill="#bfbfbf"',  'fill="#2d333b"')
    text = text.replace('fill="#e0e0e0"',  'fill="#2d333b"')
    # Lookahead avoids double-prefixing per-node texts.
    text = _re.sub(r'<text (?!fill)', '<text fill="#ffffff" ', text)
    return text


_SPILLED_PROSE_RE = re.compile(r"\b(?:This|The)\s+(?:function|method)\b")


def _hoist_spilled_param_prose(cd) -> bool:
    import xml.etree.ElementTree as _ET
    changed = False
    for md in cd.iter("memberdef"):
        if md.get("kind") != "function":
            continue
        de = md.find("detaileddescription")
        if de is None:
            continue
        # param name -> declared type, to spot callback / function-pointer params.
        ptypes: dict = {}
        for p in md.findall("param"):
            dn = (p.findtext("declname") or "").strip()
            te = p.find("type")
            if dn:
                ptypes[dn] = "".join(te.itertext()) if te is not None else ""
        hoisted: list = []
        for it in de.iter("parameteritem"):
            pd = it.find("parameterdescription")
            para = pd.find("para") if pd is not None else None
            if para is None:
                continue
            ptype = " ".join(ptypes.get("".join(n.itertext()).strip(), "")
                             for n in it.findall(".//parametername"))
            if "Callback" in ptype or "(*" in ptype or "function<" in ptype:
                continue
            text = para.text or ""
            m = _SPILLED_PROSE_RE.search(text)
            if m is None or m.start() == 0:
                continue
            keep = text[:m.start()].rstrip()
            if not keep or re.search(r"\b(?:function|callback)\b", keep, re.I):
                continue
            spill = _ET.Element("para")
            spill.text = text[m.start():]
            for child in list(para):      # all children follow the leading text
                para.remove(child)
                spill.append(child)
            para.text = keep
            hoisted.append(spill)
            changed = True
        if hoisted:
            # Insert ahead of the <para> carrying the <parameterlist>, so the
            # prose reads as body text before the parameter table.
            kids = list(de)
            at = next((i for i, p in enumerate(kids)
                       if p.find("parameterlist") is not None), len(kids))
            for off, sp in enumerate(hoisted):
                de.insert(at + off, sp)
    return changed


def _patch_namespace_xml_for_breathe(xml_dir: pathlib.Path,
                                     out_dir: pathlib.Path) -> None:
    """Mirror `xml_dir` symlinks into `out_dir`, inlining group-only memberdefs.

    breathe's by-name lookup ignores bare `<member refid>`s, so @addtogroup
    members must be copied into the compound's sectiondef for it to resolve."""
    import xml.etree.ElementTree as _ET
    import os as _osmod, shutil as _shutil
    src_index = xml_dir / "index.xml"
    # Real stamp file, not dst_index (a symlink stat() would follow).
    stamp = out_dir / ".mirror_complete"
    if (src_index.is_file() and stamp.is_file()
            and stamp.stat().st_mtime >= src_index.stat().st_mtime):
        return
    out_dir.mkdir(parents=True, exist_ok=True)

    # 1) Mirror xml_dir into out_dir as symlinks.
    for child in list(out_dir.iterdir()):
        if child.is_symlink() or child.is_file():
            child.unlink()
        elif child.is_dir():
            _shutil.rmtree(child)
    for src in xml_dir.iterdir():
        dst = out_dir / src.name
        try:
            _osmod.symlink(src, dst)
        except (OSError, NotImplementedError):
            _shutil.copy2(src, dst)

    # 2) Cache for parsed group XMLs.
    _group_cache: dict[str, _ET.ElementTree] = {}

    def _load_group(group_id: str):
        if group_id in _group_cache:
            return _group_cache[group_id]
        gx = xml_dir / f"{group_id}.xml"
        if not gx.is_file():
            _group_cache[group_id] = None
            return None
        try:
            _group_cache[group_id] = _ET.parse(gx)
        except _ET.ParseError:
            _group_cache[group_id] = None
        return _group_cache[group_id]

    # 3) Patch group-memberdef `<member refid>`s in each non-group compound.
    _SKIP_PREFIXES = ("group", "index")
    for compound_file in xml_dir.glob("*.xml"):
        if any(compound_file.name.startswith(p) for p in _SKIP_PREFIXES):
            continue
        try:
            tree = _ET.parse(compound_file)
        except _ET.ParseError:
            continue
        root = tree.getroot()
        cd = root.find("compounddef")
        if cd is None:
            continue
        existing_ids = {md.get("id") for md in cd.iter("memberdef")}
        patched = False
        for sd in cd.findall("sectiondef"):
            for member in list(sd.findall("member")):
                refid = member.get("refid", "")
                if not refid or refid in existing_ids:
                    continue
                # Compound id is the refid before "_1".
                if not refid.startswith("group__"):
                    continue
                sep = refid.find("_1")
                if sep < 0:
                    continue
                group_id = refid[:sep]
                gtree = _load_group(group_id)
                if gtree is None:
                    continue
                for md in gtree.getroot().iter("memberdef"):
                    if md.get("id") == refid:
                        sd.append(md)
                        existing_ids.add(refid)
                        patched = True
                        break
        if patched:
            out_file = out_dir / compound_file.name
            if out_file.is_symlink() or out_file.is_file():
                out_file.unlink()
            tree.write(out_file, encoding="utf-8", xml_declaration=True)

    # 3b) Hoist function-level prose that ran on into a @param description back
    #     into the function body. Runs over every mirrored compound (groups
    #     included) so the fix shows wherever breathe resolves the function.
    for compound_file in list(out_dir.glob("*.xml")):
        if compound_file.name == "index.xml":
            continue
        try:
            tree = _ET.parse(compound_file)
        except _ET.ParseError:
            continue
        cd = tree.getroot().find("compounddef")
        if cd is None:
            continue
        if _hoist_spilled_param_prose(cd):
            if compound_file.is_symlink() or compound_file.is_file():
                compound_file.unlink()
            tree.write(compound_file, encoding="utf-8", xml_declaration=True)

    # 4) Record completion.
    stamp.touch()


# -- Namespace pages ---------------------------------------------------------
# Doxygen 1.12 namespace XML lacks <innerclass>; these readers bridge it.

def _build_ns_group_map(all_refids: list[str],
                        xml_dir: pathlib.Path) -> dict[str, set]:
    """Return ``namespace_name -> set(group compound-name)``."""
    import xml.etree.ElementTree as _ET
    ns_to_groups: dict[str, set] = {}
    for refid in all_refids:
        xml_path = xml_dir / f"{refid}.xml"
        if not xml_path.is_file():
            continue
        try:
            cd = _ET.parse(xml_path).getroot().find("compounddef")
        except _ET.ParseError:
            continue
        if cd is None:
            continue
        cname = (cd.findtext("compoundname") or "").strip()
        for inn in cd.findall("innernamespace"):
            ns_xml = xml_dir / f"{inn.get('refid', '')}.xml"
            if not ns_xml.is_file():
                continue
            try:
                ns_cd = _ET.parse(ns_xml).getroot().find("compounddef")
            except _ET.ParseError:
                continue
            if ns_cd is None:
                continue
            ns_name = (ns_cd.findtext("compoundname") or "").strip()
            if not ns_name:
                continue
            if not (ns_cd.findall("sectiondef") or ns_cd.findall("innerclass")
                    or ns_cd.findall("innernamespace")):
                continue
            ns_to_groups.setdefault(ns_name, set()).add(cname)
    return ns_to_groups


def _namespaces_for_group(group_name: str, xml_dir: pathlib.Path,
                          ns_group_map: dict[str, set]) -> list[dict]:
    """Return ``{name, refid, brief, detailed}`` for ``group_name``'s namespaces."""
    import xml.etree.ElementTree as _ET, glob as _glob
    wanted = {ns for ns, grps in ns_group_map.items() if group_name in grps}
    out: list[dict] = []
    for ns_name in sorted(wanted):
        ns_file = xml_dir / ("namespace" + "_1_1".join(ns_name.split("::")) + ".xml")
        if not ns_file.is_file():
            for f in _glob.glob(str(xml_dir / "namespacecv*.xml")):
                try:
                    cd = _ET.parse(f).getroot().find("compounddef")
                except _ET.ParseError:
                    continue
                if cd is not None and (cd.findtext("compoundname") or "").strip() == ns_name:
                    ns_file = pathlib.Path(f)
                    break
        if not ns_file.is_file():
            continue
        try:
            cd = _ET.parse(ns_file).getroot().find("compounddef")
        except _ET.ParseError:
            continue
        if cd is None:
            continue
        brief = _itertext(cd.find("briefdescription"))
        detailed = _doxygen_desc_to_md(cd.find("detaileddescription"))
        out.append({"name": ns_name, "refid": cd.get("id", ""),
                    "brief": brief, "detailed": detailed})
    return out


def _read_namespace_member_sections(ns_refid: str,
                                    patched_xml_dir: pathlib.Path) -> dict:
    """Member sections for a namespace from the PATCHED XML."""
    import xml.etree.ElementTree as _ET
    if not ns_refid:
        return {}
    xml_path = patched_xml_dir / f"{ns_refid}.xml"
    if not xml_path.is_file():
        return {}
    try:
        cd = _ET.parse(xml_path).getroot().find("compounddef")
    except _ET.ParseError:
        return {}
    return _parse_member_sections(cd) if cd is not None else {}


def _namespace_innerclasses(ns_name: str, xml_dir: pathlib.Path) -> list[tuple]:
    """Return ``(refid, qualified_name, kind, brief)`` for classes directly in ``ns_name``."""
    import xml.etree.ElementTree as _ET
    ns_prefix = ns_name + "::"
    refid_prefix = ns_name.replace("::", "_1_1") + "_1_1"
    out: list[tuple] = []
    for kind in ("struct", "class"):
        for xml_file in sorted(xml_dir.glob(f"{kind}{refid_prefix}*.xml")):
            try:
                cd = _ET.parse(xml_file).getroot().find("compounddef")
            except _ET.ParseError:
                continue
            if cd is None:
                continue
            cname = (cd.findtext("compoundname") or "").strip()
            if "::" in cname[len(ns_prefix):]:   # in a sub-namespace
                continue
            out.append((xml_file.stem, cname, kind,
                        _itertext(cd.find("briefdescription"))))
    return out


__all__ = [
    "_itertext", "_type_to_md", "_doxygen_desc_to_md",
    "_enum_value_desc", "_normalize_include",
    "_MEMBERDEF_SECTIONS", "_read_class_brief",
    "_build_api_hierarchy", "_parse_member_sections", "_md_escape_cell",
    "_MEMBER_DIRECTIVE", "_MEMBER_DETAIL_SECTION", "_sphinx_cpp_v4_id",
    "_enum_synopsis_html", "_enum_synopsis_lines", "_function_signature",
    "_class_page_name", "_read_class_data", "_find_collaboration_svg",
    "_find_call_graph_svgs",
    "_svg_make_transparent", "_svg_dark_variant",
    "_patch_namespace_xml_for_breathe",
    "_build_ns_group_map", "_namespaces_for_group",
    "_read_namespace_member_sections", "_namespace_innerclasses",
]
