# 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.

"""Shared configuration & state for the OpenCV Sphinx wrapper."""
from __future__ import annotations
import pathlib, re, os as _os, shutil as _shutil, textwrap as _textwrap

HERE = pathlib.Path(__file__).resolve().parent.parent
DOC_ROOT = (HERE.parent / "doc").resolve()
OPENCV_ROOT = HERE.parent.resolve()

# SCOPE — tutorial module folders; env override or auto-discover per tree
import os as _os

def _discover_doc_modules(subdir: str, toc_glob: str) -> list[str]:
    """Folder names under DOC_ROOT/<subdir> that carry a TOC page (sorted)."""
    root = DOC_ROOT / subdir
    if not root.is_dir():
        return []
    return sorted(p.name for p in root.iterdir()
                  if p.is_dir() and any(p.glob(toc_glob)))

def _module_list(env_var: str, subdir: str, toc_glob: str) -> list[str]:
    """Env-var override (comma list) when set & non-empty, else auto-discover."""
    val = _os.environ.get(env_var)
    if val:
        return [m.strip() for m in val.split(",") if m.strip()]
    return _discover_doc_modules(subdir, toc_glob)

DOC_MODULES = _module_list(
    "OPENCV_DOC_MODULES", "tutorials", "table_of_content_*.markdown")
JS_DOC_MODULES = _module_list(
    "OPENCV_JS_DOC_MODULES", "js_tutorials", "js_table_of_contents_*.markdown")
PY_DOC_MODULES = _module_list(
    "OPENCV_PY_DOC_MODULES", "py_tutorials", "py_table_of_contents_*.markdown")

# SCOPE — env OPENCV_CONTRIB_MODULES; empty/unset auto-discovers
CONTRIB_ROOT = pathlib.Path(
    _os.environ.get("OPENCV_CONTRIB_ROOT")
    or str(HERE.parent.parent / "opencv_contrib" / "modules")
).resolve()

def _discover_contrib_modules() -> list[str]:
    """Contrib module folders carrying a `tutorials/` subtree (CMake's gate)."""
    if not CONTRIB_ROOT.is_dir():
        return []
    return sorted(p.name for p in CONTRIB_ROOT.iterdir()
                  if p.is_dir() and (p / "tutorials").is_dir())

_contrib_env = _os.environ.get("OPENCV_CONTRIB_MODULES")
CONTRIB_MODULES = ([m.strip() for m in _contrib_env.split(",") if m.strip()]
                   if _contrib_env else _discover_contrib_modules())

# SCOPE — env OPENCV_API_MODULES (comma/semicolon); empty disables API pages
def _discover_api_modules() -> list[str]:
    """Main + contrib modules that declare an `@defgroup`, by module dir name.

    The `@defgroup` is not always in the umbrella `opencv2/<module>.hpp`: some
    modules declare it in a sub-header (datasets/dataset.hpp,
    quality/qualitybase.hpp, reg/map.hpp) or a differently-named top header
    (dnn_objdetect's core_detect.hpp, cannops' cann.hpp). So scan every header
    under each module's include/ tree, and use the module DIRECTORY name (which
    equals the top `@defgroup` / `group__<name>` by convention) as the id."""
    found: set[str] = set()
    _roots = [OPENCV_ROOT / "modules"]
    if CONTRIB_ROOT.is_dir():
        _roots.append(CONTRIB_ROOT)

    def _has_defgroup(hdr) -> bool:
        try:
            return "@defgroup" in hdr.read_text(encoding="utf-8", errors="ignore")
        except OSError:
            return False

    for _root in _roots:
        if not _root.is_dir():
            continue
        for _mod in _root.iterdir():
            _inc = _mod / "include" / "opencv2"
            if not _inc.is_dir():
                continue
            # Umbrella / top-level headers first (cheap, the common case)…
            if any(_has_defgroup(h) for h in sorted(_inc.glob("*.hpp"))):
                found.add(_mod.name)
                continue
            # …else fall back to sub-directory headers.
            for _h in _inc.rglob("*.hpp"):
                if _has_defgroup(_h):
                    found.add(_mod.name)
                    break
    return sorted(found)


# Default = discovered full set; override (comma/semicolon), empty disables
API_MODULES = [
    m.strip()
    for m in re.split(r"[,;]", _os.environ.get("OPENCV_API_MODULES")
                      or ",".join(_discover_api_modules()))
    if m.strip()
]

# Sphinx srcdir; env OPENCV_SPHINX_INPUT_ROOT (`or` treats empty as unset)
SPHINX_INPUT_ROOT = pathlib.Path(
    _os.environ.get("OPENCV_SPHINX_INPUT_ROOT") or str(DOC_ROOT)
).resolve()

# sphinx_design availability
try:
    import sphinx_design as _sphinx_design  # noqa: F401
    HAVE_SPHINX_DESIGN = True
except ImportError:
    HAVE_SPHINX_DESIGN = False

# -- Breathe (Doxygen XML -> Sphinx C++ domain) -----------------------------
# env OPENCV_DOXYGEN_XML_DIR
_API_XML_DIR = pathlib.Path(
    _os.environ.get("OPENCV_DOXYGEN_XML_DIR")
    or str(HERE.parent.parent / "build_doc" / "doc" / "doxygen" / "xml")
).resolve()
# Patched namespace XML for breathe; see _patch_namespace_xml_for_breathe
_PATCHED_XML_DIR = _API_XML_DIR.parent / "xml_for_sphinx"


def _module_group_stem(m: str) -> str:
    # Doxygen names a group after its @defgroup, which can differ from the
    # module folder (folder `3d` -> `@defgroup _3d`). Return that real stem.
    if (_API_XML_DIR / f"group__{m.replace('_', '__')}.xml").is_file():
        return m
    for _root in (OPENCV_ROOT / "modules", CONTRIB_ROOT / "modules", CONTRIB_ROOT):
        _hdr = _root / m / "include" / "opencv2" / f"{m}.hpp"
        if _hdr.is_file():
            _mm = re.search(r"[@\\]defgroup\s+(\S+)",
                            _hdr.read_text(encoding="utf-8", errors="ignore"))
            return _mm.group(1) if _mm else m
    return m

# -- Python enum/constant signatures ----------------------------------------
# C++ enumerator FQN -> cv2.* name; env OPENCV_PYTHON_SIGNATURES_FILE
_PY_SIGNATURES: dict = {}
import json as _json
for _pysigs_candidate in (
    _API_XML_DIR.parents[2] / "modules" / "python_bindings_generator"
        / "pyopencv_signatures.json",
    _os.environ.get("OPENCV_PYTHON_SIGNATURES_FILE") or None,
):
    if not _pysigs_candidate:
        continue
    _pysigs_path = pathlib.Path(str(_pysigs_candidate))
    if _pysigs_path.is_file():
        try:
            _PY_SIGNATURES = _json.loads(_pysigs_path.read_text(encoding="utf-8"))
        except (ValueError, OSError):
            _PY_SIGNATURES = {}
        break


def _python_enum_name(enum_qualified: str, value_name: str,
                      strong: bool) -> str | None:
    """Return the cv2.* Python name for one C++ enumerator, or None."""
    if not _PY_SIGNATURES:
        return None
    if strong:
        scope = enum_qualified
    elif "::" in enum_qualified:
        scope = enum_qualified.rsplit("::", 1)[0]
    else:
        scope = ""
    cpp_key = f"{scope}::{value_name}" if scope else value_name
    entries = _PY_SIGNATURES.get(cpp_key)
    if entries:
        return entries[0].get("name")
    return None


# -- Breathe availability ----------------------------------------------------
# Absence empties API_MODULES
HAVE_BREATHE = False
if API_MODULES:
    try:
        import breathe  # noqa: F401
        HAVE_BREATHE = True
    except ImportError:
        API_MODULES = []

# -- Doxygen integration -----------------------------------------------------
DOXYGEN_BASE_URL = (
    _os.environ.get("OPENCV_DOXYGEN_BASE_URL", "https://docs.opencv.org/5.x/")
    .rstrip("/") + "/")
# First existing wins; env OPENCV_DOXYGEN_TAGFILE overrides
_TAG_CANDIDATES = (
    # Track cmake's actual build dir via the XML dir.
    _API_XML_DIR.parent / "html" / "opencv.tag",
    _API_XML_DIR.parent / "opencv.tag",
    HERE.parent / "build" / "doc" / "doxygen" / "html" / "opencv.tag",
    HERE.parent.parent / "build" / "doc" / "doxygen" / "html" / "opencv.tag",
    # extra build-dir layouts (vanilla, contrib, nested CI)
    HERE.parent.parent / "build" / "doc" / "opencv.tag",
    HERE.parent.parent / "build_contrib" / "doc" / "doxygen" / "html" / "opencv.tag",
    HERE.parent.parent / "build_contrib" / "doc" / "opencv.tag",
    HERE.parent.parent / "build" / "build_contrib" / "build_contrib"
        / "doc" / "doxygen" / "html" / "opencv.tag",
)
_TAG_FILE = pathlib.Path(_os.environ.get(
    "OPENCV_DOXYGEN_TAGFILE",
    str(next((p for p in _TAG_CANDIDATES if p.is_file()), _TAG_CANDIDATES[0])),
))

# anchor -> doxygen URL filename
_TAG_FILENAMES: dict[str, str] = {}
# anchor -> title
_TAG_TITLES: dict[str, str] = {}
# Page-only subset of _TAG_TITLES (kind="page" compounds only — NOT groups/files),
# i.e. Doxygen's "Related Pages" set: \page docs like intro, faq, cuda_intro, …
_DOC_PAGE_TITLES: dict[str, str] = {}
# (compound-stem, member-name, normalized-args) -> Doxygen HTML anchor.
# Bridges our XML-driven members to the HTML anchors that name the call/caller
# graph SVGs (XML memberdef ids and HTML anchors live in disjoint hash spaces).
_CALL_GRAPH_ANCHORS: dict[tuple[str, str, str], str] = {}
# Doxygen member anchor -> Sphinx anchor (lowercased member name). Lets diagram
# cross-links jump to the exact member, as the original Doxygen pages did.
_DOXY_ANCHOR_TO_MEMBER: dict[str, str] = {}


def _norm_args(arglist: str) -> str:
    """Normalize a C++ arg-list so an XML `argsstring` matches a tag `arglist`."""
    import html as _html
    return re.sub(r"\s+", "", _html.unescape(arglist or ""))
# cv-namespace short-name -> doxygen URL; used by step 7c
_CV_SYMBOL_URL: dict[str, str] = {}
# include-path -> Doxygen HTML file URL; linkifies enum `#include` lines.
_FILE_URL: dict[str, str] = {}
if _TAG_FILE.is_file():
    try:
        import xml.etree.ElementTree as _ET
        _tag_root = _ET.parse(str(_TAG_FILE)).getroot()
        for _c in _tag_root.iter("compound"):
            _kind = _c.get("kind")
            # Call/caller-graph anchors: every compound's function members,
            # keyed by the page they're documented on (the SVG filename prefix).
            for _fm in _c.findall("member"):
                if _fm.get("kind") != "function":
                    continue
                _fn = _fm.findtext("name")
                _faf = _fm.findtext("anchorfile") or ""
                _fan = _fm.findtext("anchor") or ""
                if not (_fn and _faf and _fan):
                    continue
                _fstem = pathlib.Path(_faf).stem
                _CALL_GRAPH_ANCHORS.setdefault(
                    (_fstem, _fn, _norm_args(_fm.findtext("arglist") or "")), _fan)
                # Doxygen anchor -> Sphinx member anchor (lowercased name).
                _DOXY_ANCHOR_TO_MEMBER.setdefault(_fan, _fn.lower())
            if _kind == "page":
                _n, _f = _c.findtext("name"), _c.findtext("filename")
                _t = _c.findtext("title")
                if _n and _f:
                    _TAG_FILENAMES[_n] = _f if _f.endswith(".html") else _f + ".html"
                if _n and _t:
                    _TAG_TITLES[_n] = _t
                    _DOC_PAGE_TITLES[_n] = _t
            elif _kind == "namespace" and _c.findtext("name") == "cv":
                for _m in _c.findall("member"):
                    _n = _m.findtext("name")
                    _af = _m.findtext("anchorfile")
                    _an = _m.findtext("anchor") or ""
                    if not (_n and _af):
                        continue
                    _CV_SYMBOL_URL.setdefault(
                        _n, DOXYGEN_BASE_URL + _af + (f"#{_an}" if _an else "")
                    )
            elif _kind in ("class", "struct"):
                _full = _c.findtext("name") or ""
                # Skip G-API/detail internals; a bare cv::Point isn't gapi::own::Point.
                if _full.startswith("cv::") and not any(
                        _x in _full for _x in ("::own::", "::wip::", "::detail::")):
                    _short = _full.split("::")[-1]
                    _af = _c.findtext("filename")
                    # Prefer canonical cv::<short> over nested homonyms.
                    if _short and _af and (
                            _short not in _CV_SYMBOL_URL or _full == f"cv::{_short}"):
                        _CV_SYMBOL_URL[_short] = DOXYGEN_BASE_URL + _af
            elif _kind == "group":
                # module pages are kind="group"
                _n = _c.findtext("name")
                _f = _c.findtext("filename")
                _t = _c.findtext("title")
                if _n and _f:
                    _TAG_FILENAMES[_n] = _f if _f.endswith(".html") else _f + ".html"
                if _n and _t:
                    _TAG_TITLES[_n] = _t
            elif _kind == "file":
                # Header file -> Doxygen page; key by include path.
                _n = _c.findtext("name") or ""
                _p = _c.findtext("path") or ""
                _f = _c.findtext("filename") or ""
                if _n and _f and not _p.startswith("/"):
                    _key = (_p + _n) if _p.endswith("/") or not _p else f"{_p}/{_n}"
                    _FILE_URL.setdefault(
                        _key, _f if _f.endswith(".html") else _f + ".html")
            else:
                # CV_* macros (kind="define") re-exported as cv.CV_*
                for _m in _c.findall("member"):
                    if _m.get("kind") != "define":
                        continue
                    _n = _m.findtext("name") or ""
                    if not _n.startswith("CV_"):
                        continue
                    _af = _m.findtext("anchorfile")
                    _an = _m.findtext("anchor") or ""
                    if _af:
                        _CV_SYMBOL_URL.setdefault(
                            _n, DOXYGEN_BASE_URL + _af + (f"#{_an}" if _an else "")
                        )
    except Exception:
        pass

# Canonical public header for each optional module that may be absent from a
# build's Doxygen group XML. Ensure a `_FILE_URL` entry exists so the umbrella
# `#include` on the module's placeholder page resolves (and a file-reference
# page is generated for it). `setdefault` → a real tag entry always wins.
_FALLBACK_MODULE_HEADERS: dict[str, str] = {
    "datasets":      "opencv2/datasets/dataset.hpp",
    "dnn_objdetect": "opencv2/core_detect.hpp",
    "quality":       "opencv2/quality.hpp",
    "reg":           "opencv2/reg/map.hpp",
    "cannops":       "opencv2/cann.hpp",
}
# Every header path referenced by a fallback page (umbrella + per-function
# includes), ensured present in `_FILE_URL` so each `#include` resolves to a
# generated file-reference page. `setdefault` → real tag entries always win.
_FALLBACK_FILE_HEADERS: list[str] = [
    "opencv2/datasets/dataset.hpp", "opencv2/datasets/util.hpp",
    "opencv2/core_detect.hpp",
    "opencv2/quality.hpp", "opencv2/quality/qualitybase.hpp",
    "opencv2/reg/map.hpp", "opencv2/reg/mapper.hpp",
    "opencv2/cann.hpp", "opencv2/cann_interface.hpp",
]
for _inc in list(_FALLBACK_MODULE_HEADERS.values()) + _FALLBACK_FILE_HEADERS:
    # Doxygen file-page stem: `.`->`_8`, `_`->`__` on the bare filename.
    _fn = _inc.rsplit("/", 1)[-1].replace("_", "__").replace(".", "_8")
    _FILE_URL.setdefault(_inc, f"{_fn}.html")

def _doxygen_url(page: str) -> str:
    return DOXYGEN_BASE_URL + _TAG_FILENAMES.get(page, page)


# -- Live (docs.opencv.org) tagfile for API stub URL construction ----------
# CREATE_SUBDIRS=NO flat names 404 live; live tag has subdir prefixes
_LIVE_TAG_FILE = pathlib.Path(_os.environ.get(
    "OPENCV_DOXYGEN_LIVE_TAGFILE",
    str(HERE.parent.parent / "build" / "doc" / "doxygen" / "opencv-live.tag"),
))
if not _LIVE_TAG_FILE.is_file():
    for _alt in (
        HERE.parent.parent / "build" / "build_contrib" / "build_contrib"
            / "doc" / "doxygen" / "opencv-live.tag",
        HERE.parent.parent / "build_contrib" / "doc" / "doxygen" / "opencv-live.tag",
    ):
        if _alt.is_file():
            _LIVE_TAG_FILE = _alt
            break

_LIVE_GROUP_URL: dict[str, str] = {}    # 'group__core__basic' -> live URL
_LIVE_CLASS_URL: dict[str, str] = {}    # 'Matx' -> live URL
_LIVE_TYPEDEF_URL: dict[str, str] = {}  # 'uchar' -> live URL (group anchor)
if _LIVE_TAG_FILE.is_file():
    try:
        import xml.etree.ElementTree as _ET
        for _c in _ET.parse(str(_LIVE_TAG_FILE)).getroot().iter("compound"):
            _kind = _c.get("kind")
            _n = _c.findtext("name") or ""
            _f = _c.findtext("filename") or ""
            if not (_n and _f):
                continue
            _fn = _f if _f.endswith(".html") else _f + ".html"
            if _kind == "group":
                # key by filename basename
                _basename = pathlib.PurePosixPath(_fn).name[:-5]  # strip .html
                _LIVE_GROUP_URL[_basename] = DOXYGEN_BASE_URL + _fn
            elif _kind == "class":
                _short = _n.split("::")[-1]
                _LIVE_CLASS_URL.setdefault(_short, DOXYGEN_BASE_URL + _fn)
            # typedef members -> live anchor URLs
            for _mem in _c.findall("member"):
                if _mem.get("kind") != "typedef":
                    continue
                _mn = (_mem.findtext("name") or "").strip()
                _maf = (_mem.findtext("anchorfile") or "").strip()
                _man = (_mem.findtext("anchor") or "").strip()
                if _mn and _maf and _man:
                    _LIVE_TYPEDEF_URL.setdefault(
                        _mn, f"{DOXYGEN_BASE_URL}{_maf}#{_man}")
    except Exception:
        pass


# -- Local-link variants of the maps above (step 8g) ------------------------
_LOCAL_SRC_TAG = _TAG_FILE if _TAG_FILE.is_file() else _LIVE_TAG_FILE
_LOCAL_CLASS_URL: dict[str, str] = {}
# NOTE: `_Tp` (the template-parameter placeholder) used to map to
# `class_Tp.html`, but no stub page is actually written for it — every
# `<a href="class_Tp.html">` was a 404. Removing the entry leaves `_Tp`
# tokens as plain (non-clickable) text in signatures, matching the
# "no 404 clickables" requirement.
_LOCAL_TYPEDEF_URL: dict[str, str] = {}  # 'uchar' -> 'core_hal_interface.html#uchar'
# Group-enum refid -> page#anchor; lets enum-VALUE <ref>s link to their enum doc.
_ENUM_REF_URL: dict[str, str] = {}
# Doxygen template-param placeholder pages -> (display name, Sphinx page).
# Sphinx mirrors these as stubs (stubs._write_placeholder_stubs) so diagram
# cross-links resolve instead of 404ing.
_PLACEHOLDER_STUBS: dict[str, tuple[str, str]] = {
    "class__Tp.html":        ("_Tp",        "class_Tp.html"),
    "classfloat__type.html": ("float_type", "classfloat_type.html"),
}
# Doxygen compound filename -> Sphinx page filename (class/struct compounds).
_LOCAL_PAGE_BY_DOXY_FILE: dict[str, str] = {
    _doxy: _page for _doxy, (_disp, _page) in _PLACEHOLDER_STUBS.items()}
_cls_cands: dict[str, set] = {}  # short name -> set of full names (collision check)
if _LOCAL_SRC_TAG.is_file():
    try:
        import xml.etree.ElementTree as _ET
        for _c in _ET.parse(str(_LOCAL_SRC_TAG)).getroot().iter("compound"):
            if _c.get("kind") in ("class", "struct"):
                _n = _c.findtext("name") or ""
                _f = _c.findtext("filename") or ""
                if _n and _f:
                    _short = _n.split("::")[-1]
                    _fn = _f if _f.endswith(".html") else _f + ".html"
                    _doxy_base = pathlib.PurePosixPath(_fn).name
                    # Skip G-API/detail internals as bare `cv::` targets.
                    if not any(_x in _n for _x in ("::own::", "::wip::", "::detail::")):
                        _cls_cands.setdefault(_short, set()).add(_n)
                        # Prefer canonical cv::<short> over nested homonyms.
                        if _short not in _LOCAL_CLASS_URL or _n == f"cv::{_short}":
                            _LOCAL_CLASS_URL[_short] = _doxy_base
                    # Sphinx mirrors Doxygen's filename except the few remapped.
                    _LOCAL_PAGE_BY_DOXY_FILE.setdefault(
                        _doxy_base, _LOCAL_CLASS_URL.get(_short, _doxy_base))
            for _mem in _c.findall("member"):
                # variable only from namespaces; class-member vars poison the map
                _mk = _mem.get("kind")
                if _mk == "typedef":
                    pass
                elif _mk == "enumeration":
                    pass   # enum types like cv::DataLayout — linkable
                elif _mk == "variable" and _c.get("kind") == "namespace":
                    pass
                else:
                    continue
                _mn = (_mem.findtext("name") or "").strip()
                _maf = (_mem.findtext("anchorfile") or "").strip()
                _man = (_mem.findtext("anchor") or "").strip()
                if not (_mn and _maf):
                    continue
                # The tagfile emits each typedef twice: once with the
                # bare name (`InputArray`) and once fully-qualified
                # (`cv::InputArray`). Normalize to the bare short name
                # so we don't end up with a second map entry whose
                # anchor literally contains `cv::` (e.g.
                # `core_basic.html#cv::inputarray`) — that anchor never
                # exists on the rendered page and every `cv::InputArray`
                # codespan ends up 404-ing.
                _short_mn = _mn.split("::")[-1]
                if _short_mn in _LOCAL_TYPEDEF_URL:
                    continue   # first-occurrence wins
                _bn = pathlib.PurePosixPath(_maf).name
                if _bn.startswith("group__"):
                    # group__core__hal__interface.html -> core_hal_interface.html
                    _local_page = (_bn[len("group__"):]
                                   .replace(".html", "")
                                   .replace("__", "_")
                                   + ".html")
                elif _bn.startswith("namespacecv"):
                    _local_page = "core_basic.html"
                else:
                    _local_page = _bn
                # Anchor: mirror exactly the MyST target the detail block
                # emits, since docutils' make_id lowercases the label and
                # collapses every run of non-alnum chars to a single "-".
                # The label differs by kind/where it renders:
                #
                #  * typedefs, namespace variables, and CLASS-member enums
                #    render via `_render_member_detail` / the class-enum
                #    path, both keyed by the Doxygen member refid
                #    `({m['id']})=` (= "<anchorfile-stem>_1<anchor>").
                #    (The old `_CPPv4…` cpp-domain id was never emitted for
                #    these hand-rolled markdown sections, so every such
                #    cross-reference 404'd.)
                #
                #  * GROUP/namespace enums render (stubs, kind_key=="enum")
                #    with `({_sphinx_cpp_v4_id(qualified)})=` instead — the
                #    member refid is NOT used — so their anchor is the
                #    mangled, lowercased cpp-v4 id, not the refid.
                _is_class_pg = _bn.startswith(("class", "struct", "union"))
                if _mk == "enumeration" and not _is_class_pg:
                    _ns = (_c.findtext("name") or "").strip()
                    _qual = _mn if "::" in _mn else (
                        f"{_ns}::{_mn}" if _ns and _c.get("kind") == "namespace"
                        else _mn)
                    # _sphinx_cpp_v4_id: "_CPPv4N" + join(f"{len(p)}{p}") + "E"
                    _label = ("_CPPv4N"
                              + "".join(f"{len(_p)}{_p}" for _p in _qual.split("::"))
                              + "E")
                else:
                    _stem = _bn[:-5] if _bn.endswith(".html") else _bn
                    _label = f"{_stem}_1{_man}" if _man else _stem
                _anchor = re.sub(r"[^a-z0-9]+", "-", _label.lower()).strip("-")
                _LOCAL_TYPEDEF_URL[_mn] = f"{_local_page}#{_anchor}"
                # Index group enums by refid so enum-VALUE <ref>s can resolve here.
                if _mk == "enumeration" and not _is_class_pg and _man:
                    _est = _bn[:-5] if _bn.endswith(".html") else _bn
                    _ENUM_REF_URL.setdefault(f"{_est}_1{_man}",
                                             f"{_local_page}#{_anchor}")
    except Exception:
        pass
    # Drop ambiguous short names (>1 class, no canonical cv::<short>) so a bare
    # `cv::Point` stays plain text instead of linking to a wrong homonym.
    for _s, _cands in _cls_cands.items():
        if len(_cands) > 1 and f"cv::{_s}" not in _cands:
            _LOCAL_CLASS_URL.pop(_s, None)


def _doxy_page_to_local(basename: str) -> str:
    """Map a Doxygen compound page filename to the Sphinx page for the same
    symbol. Pure name transform — the caller verifies the file exists.
        group__core__utils.html -> core_utils.html
        namespacecv*.html       -> core_basic.html
        class*/struct*/union*   -> unchanged (or remapped, e.g. the _Tp stub)
    """
    if basename.startswith("group__"):
        return (basename[len("group__"):].replace(".html", "")
                .replace("__", "_") + ".html")
    if basename.startswith("namespace"):
        return "core_basic.html"
    return _LOCAL_PAGE_BY_DOXY_FILE.get(basename, basename)


# -- Class template-parameter display map (step 8e) -------------------------
# class short name -> template-param list e.g. '< _Tp, cn >'
_CLASS_TEMPLATE_DISPLAY: dict[str, str] = {}
if _API_XML_DIR.is_dir():
    try:
        import xml.etree.ElementTree as _ET
        for _xml in _API_XML_DIR.glob("classcv_1_1*.xml"):
            try:
                _cd = _ET.parse(_xml).getroot().find("compounddef")
            except _ET.ParseError:
                continue
            if _cd is None:
                continue
            _tpl = _cd.find("templateparamlist")
            if _tpl is None:
                continue
            _names = []
            for _p in _tpl.findall("param"):
                _decl = (_p.findtext("declname")
                         or _p.findtext("defname") or "").strip()
                _type = (_p.findtext("type") or "").strip()
                if _decl:
                    _names.append(_decl)
                elif _type in ("typename", "class"):
                    _names.append("_Tp")
                elif _type:
                    _names.append(_type)
            if _names:
                _name = (_cd.findtext("compoundname") or "").split("::")[-1]
                _CLASS_TEMPLATE_DISPLAY[_name] = f"< {', '.join(_names)} >"
    except Exception:
        pass


# Punctuation -> alpha token so operator overloads get distinct slugs
_FUNC_SLUG_PUNCT = {
    "=": "eq", "!": "ne", "<": "lt", ">": "gt", "+": "plus", "-": "minus",
    "*": "mul", "/": "div", "&": "amp", "|": "or", "%": "mod", "^": "xor",
    "~": "tilde", "[": "lbr", "]": "rbr",
}


def _func_slug(name: str) -> str:
    """In-page anchor slug for a function name; shared by stub gen and translator."""
    parts = []
    for ch in name.lower():
        if ch.isalnum() or ch == "_":
            parts.append(ch)
        elif ch in _FUNC_SLUG_PUNCT:
            parts.append("-" + _FUNC_SLUG_PUNCT[ch])
        else:
            parts.append("-")
    s = re.sub(r"-+", "-", "".join(parts)).strip("-")
    return f"cv-{s}" if s else "cv"


# ---- Citation numbering --------------------------------------------------
# @cite KEY -> [N], N = bibtex-plain sort position
def _bib_parse(text: str) -> list[dict]:
    """Walk a BibTeX file into a list of {_type, _key, field: value, ...}.

    Tolerant of a missing closing `}` (as bibtex/Doxygen are): a new
    `@type{key,` at the start of a line implicitly closes the previous entry,
    so one malformed record can't swallow the rest of the file. (e.g.
    ximgproc.bib's `akinlar201782` has no closing brace upstream.)"""
    out: list[dict] = []
    n = len(text)
    header = re.compile(r"@(\w+)\s*\{\s*([^\s,]+)\s*,")
    # Every entry header that begins a line is a hard boundary.
    starts = [m.start() for m in
              re.finditer(r"(?m)^[^\S\n]*@\w+\s*\{\s*[^\s,]+\s*,", text)]
    for idx, s in enumerate(starts):
        m = header.match(text, s)
        if not m:
            continue
        kind, key = m.group(1), m.group(2)
        body_start = m.end()
        limit = starts[idx + 1] if idx + 1 < len(starts) else n
        # Matching close within this entry's span; if unbalanced (missing `}`),
        # fall back to the next entry's boundary, trimming a trailing brace.
        depth, i, end = 1, body_start, None
        while i < limit:
            c = text[i]
            if c == "{":
                depth += 1
            elif c == "}":
                depth -= 1
                if depth == 0:
                    end = i
                    break
            i += 1
        body = (text[body_start:end] if end is not None
                else text[body_start:limit].rstrip().rstrip("}"))
        out.append({"_type": kind.lower(), "_key": key, **_bib_fields(body)})
    return out

def _bib_fields(body: str) -> dict[str, str]:
    fields: dict[str, str] = {}
    n, j = len(body), 0
    while j < n:
        while j < n and body[j] in " \t\n\r,":
            j += 1
        if j >= n:
            break
        ns = j
        while j < n and (body[j].isalnum() or body[j] == "_"):
            j += 1
        name = body[ns:j].lower()
        if not name:
            break
        while j < n and body[j] in " \t\n\r":
            j += 1
        if j >= n or body[j] != "=":
            break
        j += 1
        while j < n and body[j] in " \t\n\r":
            j += 1
        if j >= n:
            break
        if body[j] == "{":
            j += 1
            depth, vs = 1, j
            while j < n and depth > 0:
                if body[j] == "{":
                    depth += 1
                elif body[j] == "}":
                    depth -= 1
                j += 1
            value = body[vs:j - 1]
        elif body[j] == '"':
            j += 1
            vs = j
            while j < n and body[j] != '"':
                if body[j] == "\\" and j + 1 < n:
                    j += 1
                j += 1
            value = body[vs:j]
            if j < n:
                j += 1
        else:
            vs = j
            while j < n and body[j] not in ",\n":
                j += 1
            value = body[vs:j].strip()
        fields[name] = value
    return fields

# LaTeX accent + special-char cleanup
_LATEX_ACCENT_RE = re.compile(r"\\([\"'`^~.])\s*\{?\s*([A-Za-z])\s*\}?")
_LATEX_ACCENT_MAP = {
    ('"', 'a'): 'ä', ('"', 'e'): 'ë', ('"', 'i'): 'ï', ('"', 'o'): 'ö',
    ('"', 'u'): 'ü', ('"', 'A'): 'Ä', ('"', 'O'): 'Ö', ('"', 'U'): 'Ü',
    ("'", 'a'): 'á', ("'", 'e'): 'é', ("'", 'i'): 'í', ("'", 'o'): 'ó',
    ("'", 'u'): 'ú', ("'", 'c'): 'ć', ("'", 'n'): 'ń', ("'", 'A'): 'Á',
    ("'", 'E'): 'É', ("'", 'I'): 'Í', ("'", 'O'): 'Ó', ("'", 'U'): 'Ú',
    ("`", 'a'): 'à', ("`", 'e'): 'è', ("`", 'i'): 'ì', ("`", 'o'): 'ò',
    ("`", 'u'): 'ù',
    ('^', 'a'): 'â', ('^', 'e'): 'ê', ('^', 'i'): 'î', ('^', 'o'): 'ô',
    ('^', 'u'): 'û',
    ('~', 'a'): 'ã', ('~', 'n'): 'ñ', ('~', 'o'): 'õ',
    ('.', 'c'): 'ċ', ('.', 'e'): 'ė',
}
_LATEX_SPECIAL = {
    r"\&": "&", r"\%": "%", r"\#": "#", r"\$": "$",
    r"\_": "_", r"\{": "{", r"\}": "}",
    r"\textendash": "–", r"\textemdash": "—",
    r"\ldots": "…", r"\dots": "…",
    r"\o": "ø", r"\O": "Ø", r"\ss": "ß",
    r"\aa": "å", r"\AA": "Å", r"\ae": "æ", r"\AE": "Æ",
    "---": "—", "--": "–",
}

def _bib_clean(s: str) -> str:
    s = re.sub(r"\s+", " ", s or "").strip()
    s = _LATEX_ACCENT_RE.sub(
        lambda m: _LATEX_ACCENT_MAP.get((m.group(1), m.group(2)), m.group(2)), s)
    for k, v in _LATEX_SPECIAL.items():
        s = s.replace(k, v)
    return s.replace("{", "").replace("}", "").strip()

def _bib_join_authors(field: str) -> str:
    """Render an author/editor list bibtex-plain style."""
    if not field:
        return ""
    parts = re.split(r"\s+and\s+", field)
    out: list[str] = []
    for p in parts:
        p = _bib_clean(p)
        if "," in p:  # "Last, First" → "First Last"
            last, first = p.split(",", 1)
            p = f"{first.strip()} {last.strip()}"
        out.append(p)
    if len(out) <= 1:
        return out[0] if out else ""
    if len(out) == 2:
        return f"{out[0]} and {out[1]}"
    return ", ".join(out[:-1]) + f", and {out[-1]}"

def _bib_render_entry(e: dict, num: int | None) -> str:
    key = e["_key"]
    bracket = f"[{num}]" if num is not None else f"[{key}]"
    authors = _bib_join_authors(e.get("author") or e.get("editor") or "")
    title  = _bib_clean(e.get("title", ""))
    year   = _bib_clean(e.get("year", ""))
    month  = _bib_clean(e.get("month", ""))
    pages  = _bib_clean(e.get("pages", ""))
    volume = _bib_clean(e.get("volume", ""))
    number = _bib_clean(e.get("number", ""))
    doi    = _bib_clean(e.get("doi", ""))
    url    = _bib_clean(e.get("url", ""))
    journal   = _bib_clean(e.get("journal", ""))
    booktitle = _bib_clean(e.get("booktitle", ""))
    publisher = _bib_clean(e.get("publisher") or e.get("howpublished")
                           or e.get("institution") or "")

    # title links to url, else doi.org
    title_url = url or (f"https://doi.org/{doi}" if doi else "")
    title_md = f"[{title}]({title_url})" if (title and title_url) else title

    date = (f"{month} {year}".strip()) if (month or year) else ""

    bits: list[str] = []
    if authors:
        bits.append(authors)
    if title_md:
        bits.append(title_md)

    # venue formatting by entry kind
    kind = e.get("_type", "")
    if kind == "article" and journal:
        seg = f"*{journal}*"
        if volume:
            seg += f", {volume}" + (f"({number})" if number else "")
            if pages:
                seg += f":{pages}"
        elif pages:
            seg += f", {pages}"
        if date:
            seg += f", {date}"
        bits.append(seg)
    elif kind in ("inproceedings", "incollection") and booktitle:
        seg = f"In *{booktitle}*"
        if pages:
            seg += f", pages {pages}"
        if publisher:
            seg += f". {publisher}"
        if date:
            seg += f", {date}"
        bits.append(seg)
    else:
        # @book / @misc / fallback
        tail = []
        if publisher:
            tail.append(publisher)
        if booktitle and not publisher:
            tail.append(f"*{booktitle}*")
        if date:
            tail.append(date)
        if tail:
            bits.append(", ".join(tail))

    body = ". ".join(bits)
    if body and not body.endswith("."):
        body += "."

    # keep CITEREF_<Key> case so cached Doxygen links resolve
    return f'<a id="CITEREF_{key}"></a>\n\n**{bracket}** {body}'

def _bib_render_all(entries: list[dict], numbering: dict[str, int]) -> str:
    out = ["Bibliography {#citelist}", "============", ""]
    for e in entries:
        out.append(_bib_render_entry(e, numbering.get(e["_key"])))
        out.append("")
    return "\n".join(out)


def _bib_sort_key(e: dict) -> tuple:
    """bibtex `plain` sort: author last name, year, title (matches live site)."""
    authors = e.get("author") or e.get("editor") or "zzz"
    first = re.split(r"\s+and\s+", authors)[0]
    first = _bib_clean(first)
    if "," in first:                       # "Last, First"
        last = first.split(",", 1)[0].strip()
    else:                                  # "First Middle Last"
        toks = first.split()
        last = toks[-1] if toks else "zzz"
    return (last.lower(),
            _bib_clean(e.get("year", "")),
            _bib_clean(e.get("title", "")).lower())

# opencv.bib + per-module + contrib bibs (contrib affects sort order)
_BIB_FILES: list[pathlib.Path] = []
if (DOC_ROOT / "opencv.bib").is_file():
    _BIB_FILES.append(DOC_ROOT / "opencv.bib")
_BIB_FILES += sorted((OPENCV_ROOT / "modules").glob("*/doc/*.bib"))
if CONTRIB_ROOT.is_dir():
    _BIB_FILES += sorted(CONTRIB_ROOT.glob("*/doc/*.bib"))

_CITE_NUMBER: dict[str, int] = {}
# reused by citelist generator for the same sort order
_BIB_ENTRIES_SORTED: list[dict] = []
_seen_keys: set[str] = set()  # dedupe; first wins
_all_entries: list[dict] = []
for _bf in _BIB_FILES:
    try:
        _txt = _bf.read_text(encoding="utf-8", errors="replace")
    except OSError:
        continue
    for _e in _bib_parse(_txt):
        if _e["_key"] in _seen_keys:
            continue
        _seen_keys.add(_e["_key"])
        _all_entries.append(_e)
_BIB_ENTRIES_SORTED = sorted(_all_entries, key=_bib_sort_key)
for _i, _e in enumerate(_BIB_ENTRIES_SORTED, 1):
    _CITE_NUMBER[_e["_key"]] = _i

_REDIRECT_MAP: dict[str, str] = {}
_REDIRECT_RE = re.compile(
    r"\{#(?P<src>[\w-]+)\}\s*\n[=\-]+\s*\n+"
    r"\s*(?:Content|Tutorial\s+content)\s+has\s+been\s+moved\b"
    r"[^@]{0,300}@ref\s+(?P<dst>[\w-]+)",
    re.IGNORECASE,
)
for _scan_dir in ("tutorials", "py_tutorials", "js_tutorials"):
    _root = DOC_ROOT / _scan_dir
    if not _root.is_dir():
        continue
    for _md in _root.rglob("*.markdown"):
        try:
            _t = _md.read_text(encoding="utf-8", errors="replace")[:2000]
        except OSError:
            continue
        _m = _REDIRECT_RE.search(_t)
        if _m:
            _REDIRECT_MAP[_m.group("src")] = _m.group("dst")

def _resolve_redirect(anchor: str) -> str:
    """Follow `_REDIRECT_MAP` transitively (cycle-safe)."""
    seen: set[str] = set()
    while anchor in _REDIRECT_MAP and anchor not in seen:
        seen.add(anchor)
        anchor = _REDIRECT_MAP[anchor]
    return anchor

# Anchor maps
_ANCHOR_TO_DOC: dict[str, str] = {}            # anchor -> docname (internal)
_ANCHOR_TO_EXTERNAL: dict[str, tuple[str, str]] = {}  # anchor -> (title, url)
_ANCHOR_TO_TITLE: dict[str, str] = {}          # anchor -> first-heading title
_LOCAL_MEMBER_IDS: set[str] = set()            # member detail targets we emit
# anchors reachable via @subpage/@ref; rest are orphans
_REFERENCED_ANCHORS: set[str] = set()

_HEAD_RE = re.compile(
    r"^(?P<title1>[^\n]+?)\s*\{#(?P<anchor1>[\w-]+)\}\s*\n[=\-]{3,}\s*$"
    r"|"
    r"^#+\s+(?P<title2>[^\n]+?)\s*\{#(?P<anchor2>[\w-]+)\}\s*$",
    re.MULTILINE)

def _scan_internal(path: pathlib.Path, base: pathlib.Path | None = None) -> None:
    """Add every {#anchor} and `@anchor NAME` in `path` to _ANCHOR_TO_DOC."""
    base = base or SPHINX_INPUT_ROOT
    _SUFFIXES = (".markdown", ".md")
    if path.is_file():
        files = [path] if path.suffix in _SUFFIXES else []
    elif path.is_dir():
        # skip _old/** (not compiled)
        files = [p for s in _SUFFIXES for p in path.rglob(f"*{s}")
                 if "_old" not in p.parts]
    else:
        files = []
    for md in files:
        try:
            body = md.read_text(encoding="utf-8", errors="replace")
        except OSError:
            continue
        # unresolved path so staged symlinks give staging-root docnames
        try:
            rel = md.relative_to(base).with_suffix("").as_posix()
        except ValueError:
            # file outside base; fall back to DOC_ROOT-relative
            rel = md.relative_to(DOC_ROOT).with_suffix("").as_posix()
        for m in re.finditer(r"\{#([\w-]+)\}", body):
            _ANCHOR_TO_DOC[m.group(1)] = rel
        for m in re.finditer(r"^@anchor\s+([\w-]+)\s*$", body, re.MULTILINE):
            _ANCHOR_TO_DOC[m.group(1)] = rel
        # anchors this file links to (orphan detection)
        for m in re.finditer(r"@(?:subpage|ref)\s+([\w-]+)", body):
            _REFERENCED_ANCHORS.add(m.group(1))
        # first heading title
        tm = _HEAD_RE.search(body[:4000])
        if tm:
            anchor = tm.group("anchor1") or tm.group("anchor2")
            title = (tm.group("title1") or tm.group("title2") or "").strip()
            if anchor and title:
                _ANCHOR_TO_TITLE[anchor] = title

def _scan_external(toc_file: pathlib.Path) -> None:
    """Add a module TOC file's top (title, anchor) to _ANCHOR_TO_EXTERNAL."""
    try:
        head = toc_file.read_text(encoding="utf-8", errors="replace")[:4000]
    except OSError:
        return
    m = _HEAD_RE.search(head)
    if not m:
        return
    anchor = m.group("anchor1") or m.group("anchor2")
    title = (m.group("title1") or m.group("title2") or "").strip()
    if not anchor:
        return
    url = DOXYGEN_BASE_URL + _TAG_FILENAMES.get(anchor, "index.html")
    _ANCHOR_TO_EXTERNAL[anchor] = (title, url)

# shared dict objects populated by the build module
_IMAGE_INDEX: dict[str, str] = {}
_SNIPPET_INDEX: dict[str, pathlib.Path] = {}

_ALL_CLASSES: dict[str, dict] = {}      # refid -> {qualified, kind, brief, docname}
_ALL_NAMESPACES: dict[str, dict] = {}   # name  -> {refid, brief, docname}

_TOGGLE_LABELS = {"cpp": "C++", "java": "Java", "python": "Python"}


# Mirrors Doxygen EXAMPLE_PATH; OPENCV_ROOT first (order matters)
_SNIPPET_BASES = [
    OPENCV_ROOT,
    OPENCV_ROOT / "samples",
    OPENCV_ROOT / "apps",
] + [CONTRIB_ROOT / _m / "samples" for _m in CONTRIB_MODULES]

# Remap Doxygen language names Pygments doesn't know
_LANG_ALIASES = {
    "none": "text",
    "unparsed": "text",
    "guess": "text",
    "gradle": "groovy",
    "run": "bash",
    # `m` = Objective-C in the iOS tutorials (.m sources); Pygments uses `objc`.
    "m": "objc",
    # No Pygments lexer for these fence tags used by ios/app/face tutorials —
    # render as plain text instead of warning "lexer name is not known".
    "csv": "text",
    "plaintext": "text",
}

USE_INDEX_LANDING = True

__all__ = [
    "USE_INDEX_LANDING",
    "HERE", "DOC_ROOT", "OPENCV_ROOT",
    "DOC_MODULES", "JS_DOC_MODULES", "PY_DOC_MODULES",
    "CONTRIB_MODULES", "CONTRIB_ROOT", "SPHINX_INPUT_ROOT", "API_MODULES",
    "_API_XML_DIR", "_PATCHED_XML_DIR", "_module_group_stem",
    "_PY_SIGNATURES", "_python_enum_name",
    "HAVE_SPHINX_DESIGN", "HAVE_BREATHE",
    "DOXYGEN_BASE_URL", "_doxygen_url",
    "_TAG_FILE", "_TAG_FILENAMES", "_TAG_TITLES", "_DOC_PAGE_TITLES",
    "_CV_SYMBOL_URL", "_FILE_URL", "_FALLBACK_MODULE_HEADERS",
    "_CALL_GRAPH_ANCHORS", "_DOXY_ANCHOR_TO_MEMBER", "_norm_args",
    "_LIVE_GROUP_URL", "_LIVE_CLASS_URL", "_LIVE_TYPEDEF_URL",
    "_LOCAL_CLASS_URL", "_LOCAL_TYPEDEF_URL", "_ENUM_REF_URL", "_CLASS_TEMPLATE_DISPLAY",
    "_LOCAL_PAGE_BY_DOXY_FILE", "_PLACEHOLDER_STUBS", "_doxy_page_to_local",
    "_func_slug",
    "_CITE_NUMBER", "_BIB_ENTRIES_SORTED", "_bib_render_all",
    "_REDIRECT_MAP", "_resolve_redirect",
    "_ANCHOR_TO_DOC", "_ANCHOR_TO_EXTERNAL", "_ANCHOR_TO_TITLE", "_LOCAL_MEMBER_IDS",
    "_REFERENCED_ANCHORS", "_HEAD_RE",
    "_scan_internal", "_scan_external",
    "_IMAGE_INDEX", "_SNIPPET_INDEX", "_SNIPPET_BASES",
    "_ALL_CLASSES", "_ALL_NAMESPACES",
    "_TOGGLE_LABELS", "_LANG_ALIASES",
]
