"""api_doc_builder.py

Create and provide links to API pages in LVGL doc build.

Uses doxygen_xml.py module to:

- Prep and run Doxygen,
- make Doxygen XML output available, and
- make Doxygen-documented symbols from the C code available.

Because these 5 files are acceptable in a C project:

1.  ./path/to/one/aaa.c
2.  ./path/to/one/aaa.h
3.  ./path/to/one/aaa.cpp
4.  ./path/to/one/aaa.hpp
5.  ./path/to/another/aaa.h

we see that:

- duplicate filename stems ('aaa' above) are acceptable, and
- they must not only be differentiated by their extensions, but also by their path.

On the other hand, Sphinx link target names for :ref:`link_ref_name` link
target names must be unique throughout a document project.  Since API pages are
generated from C source files, to make this effective, they must include:

A.  at least part of the path,
B.  filename stem, and
C.  extension

in the link target names.  Prior to 11-Aug-2025, link target names were formed
using ONLY the filename stem.  This created a conflict when an example of #4 above
appeared in the LVGL code, and caused a doc-build failure because the API-page link
target names to #2 and #4 were identical, and Sphinx (correctly) does not allow
that.  So after 11-Aug-2025, these link target names are now differentiated via all 3
of A, B and C above.

.. note::

    Since they are generated and dealt with programmatically, using the full path
    [below the docs source directory] will not be a problem.  Even though it makes
    the names long, they never have to be hand-typed.

Likewise, API-page ``.rst`` filename stems also need to be unique within the directory
they are in, so the stems must contain the C source file's filename stem plus the
extension in them, in order to be unique in that directory, since they *must* end in
``.rst``.

"""
import os
import re
import doxygen_xml
from announce import *

old_html_files = {}
EMIT_WARNINGS = True
rst_section_line_char = '='
on_windows = (( os.name == 'nt' ))
# `doxy_src_file_ext_list` must
# - match the extensions in the Doxyfile FILE_PATTERNS list, and
# - be in lower case.
doxy_src_file_ext_list = [
    '.h',
    '.hh',
    '.hxx',
    '.hpp',
    '.h++'
]

# Excluded from doxygen via EXCLUDE config item.  (This config
# item is set in `doxygen_xml.py`.)
_cfg_exclude_list = ['lv_conf_internal.h']

# Multi-line match ``API + newline + \*\*\* + whitespace``.
# NB:  the ``\s*`` at the end forces the regex to match the whitespace
# at the end including all \r\n's.  This will match UP TO:
# - the next non-blank character (could be many blank lines), or
# - to the end of the file, whichever comes first.
_re_api_section_sep = re.compile(r'(?mi)^API *\r?\n^\*\*\*\s*')

# Regex to identify '.. API equals: lv_obj_t, lv_array_t' directives.
_re_api_equals = re.compile(r'(?mi)^\s*\.\.\s+API\s+equals:\s*([\w,\s]+)')

# Regex to identify '.. API startswith: lv_obj, lv_array' directives.
_re_api_startswith = re.compile(r'(?mi)^\s*\.\.\s+API\s+startswith:\s*([\w,\s]+)')

# Regex to match comma and whitespace list-item separators on multiple lines.
_re_multi_line_comma_sep = re.compile(r'(?m)[,\s]+')

# Regex to identify editor-added hyperlinks:  :ref:`lv_obj_h`
_re_editor_added_hyperlink = re.compile(r'^\s*:ref:`([\w/\.]+)`')

# Separator to mark place where this script added hyperlinks.
_auto_gen_sep = '.. Autogenerated'

# List of symbol dictionaries
_defines = {}
_enums = {}
_variables = {}
_namespaces = {}
_structs = {}
_unions = {}
_typedefs = {}
_functions = {}

_symbol_dict_list = [
    _defines,
    _enums,
    _variables,
    _namespaces,
    _structs,
    _unions,
    _typedefs,
    _functions
]


def old_clean_name(nme):
    """Strip beginning "_lv" and ending "_t"."""
    # Handle error:
    #     AttributeError: 'NoneType' object has no attribute 'startswith'
    if nme is None:
        return nme

    if nme.startswith('_lv_'):
        nme = nme[4:]
    elif nme.startswith('lv_'):
        nme = nme[3:]

    if nme.endswith('_t'):
        nme = nme[:-2]

    return nme


# Definitions:
# - "section" => The name "abc_def" has 2 sections.
# - N = number of sections in `item_name`.
# After removing leading '_lv_', 'lv_' and trailing '_t' from `obj_name`,
# do the remaining first N "sections" of `obj_name` match `item_name`
# (case sensitive)?
def old_is_name_match(item_name, obj_name):
    # Handle error:
    #     AttributeError: 'NoneType' object has no attribute 'split'
    if obj_name is None:
        return False

    sect_count = item_name.count('_') + 1

    obj_name = obj_name.split('_')

    # Reject (False) if `obj_name` doesn't have as many sections as `item_name`.
    if len(obj_name) < sect_count:
        return False

    obj_name = '_'.join(obj_name[:sect_count])

    return item_name == obj_name


def old_get_includes(name1, name2, obj, includes):
    name2 = old_clean_name(name2)

    if not old_is_name_match(name1, name2):
        return

    if obj.parent is not None and hasattr(obj.parent, 'header_file'):
        header_file = obj.parent.header_file
    elif hasattr(obj, 'header_file'):
        header_file = obj.header_file
    else:
        return

    if not header_file:
        return

    if header_file not in old_html_files:
        return

    includes.add((header_file, old_html_files[header_file]))


def _conditionally_add_hyperlink(obj, genned_link_set: set, exclude_set: set):
    """
    Add hyperlink names to `link_set` if:
    - not in `exclude_set`, and
    - not already in `link_set`.
    :param obj:              "thing" from dictionary with matching symbol.
                               These are objects instantiated from classes
                               in `doxygen_xml` module such as
                               STRUCT, FUNCTION, DEFINE, ENUMVALUE, etc.
    :param genned_link_set:  Set in which to accumulate link names
    :param exclude_set:      Set with link names not to add to `link_set`
    :return:
    """
    if obj.file_name is not None:
        # The link name should look like this:  draw/sw/blend/lv_draw_sw_blend.h
        # including when running under Windows.
        # `obj.file_name` contains a path that looks like this:
        # '/path/to/lvgl/src/draw/sw/blend/lv_draw_sw_blend.h'
        # Note:  we can't use 'lvgl/src' because there is no guarantee
        # that the user's LVGL repo directory will be called that.
        src_root = '/src/'
        i = obj.file_name.index(src_root) + len(src_root)
        link_name = obj.file_name[i:]
        if link_name not in genned_link_set:
            if link_name not in exclude_set:
                genned_link_set.add(link_name)


def _add_startswith_matches(strings: [str], genned_link_set, editor_link_set):
    """
    Add set of hyperlinks to `genned_link_set` that are not already in
    `editor_link_set`, for C symbols that start with strings in `strings`.

    :param strings:          List of strings to match against
    :param genned_link_set:  Generated link set
    :param editor_link_set:  Hyperlinks added by editor
    :return:                 n/a
    """
    for partial_symbol in strings:
        for symbol_dict in _symbol_dict_list:
            for key in symbol_dict:
                if key is None:
                    # Dictionary `enums` has a key `None` which contains
                    # all enum values from all unnamed enums, and each
                    # enum value has a `file_name` field.
                    enum_values_list = symbol_dict[None].members
                    for enum_val in enum_values_list:
                        if enum_val.name.startswith(partial_symbol):
                            _conditionally_add_hyperlink(enum_val, genned_link_set, editor_link_set)
                else:
                    if key.startswith(partial_symbol):
                        obj = symbol_dict[key]
                        _conditionally_add_hyperlink(obj, genned_link_set, editor_link_set)


def _add_exact_matches(symbols: [str], genned_link_set, editor_link_set):
    """
    Add set of hyperlinks to `genned_link_set` that are not already in
    `editor_link_set`, for exact C symbol matches.

    :param symbols:          List of C symbols to match against
    :param genned_link_set:  Generated link set
    :param editor_link_set:  Hyperlinks added by editor
    :return:                 n/a
    """
    for symbol in symbols:
        for symbol_dict in _symbol_dict_list:
            if symbol in symbol_dict:
                obj = symbol_dict[symbol]
                _conditionally_add_hyperlink(obj, genned_link_set, editor_link_set)


def _hyperlink_sort_value(init_value: str):
    if init_value.endswith('.h'):
        result = init_value[:-2]
    else:
        result = init_value

    return result


def _process_end_of_eligible_doc(b: str, rst_file: str) -> (str, str, int):
    """
    Edit end section after API section heading.

    3.  Initialize:
        - links_added_count = 0
        - editor_link_set = set()
        - genned_link_set = set()
        - C = ''    # string for generated hyperlinks
    4.  Remove `_auto_gen_sep` and everything after it:
        - new_B = B.split(_auto_gen_sep, 1)[0]
    5.  With `new_B, add any editor-added hyperlinks to set:
        `editor_link_set`.
    6.  If `_re_api_equals` match present:
        - build list of symbols
        - compute list of hyperlinks from symbols
        - add hyperlinks to `genned_link_set` if not in `editor_link_set`.
    7.  If `_re_api_startswith` match present:
        - build list of symbols
        - compute list of hyperlinks from symbols
        - add hyperlinks to `genned_link_set` if not in `editor_link_set`.
    8.  Lacking either of the above custom directives, use the lower-case
        stem of the filename and prefix it with "lv_" and try an
        "API startswith" search with it.
    9.  If len(genned_link_set) > 0:
        - `C` = _auto_gen_sep + '\n\n' + `genned_link_set`
          (with a blank line between each).
    10. Return tuple: (new_B, C, links_added_count).

    :param b:  End of document after API section heading + whitespace.
    :return:   Tuple: (new_B, C, links_added_count)
    """
    # 3.  Initialize:
    editor_link_set = set()
    genned_link_set = set()
    c = ''
    api_directives_found_count = 0

    # 4.  Remove `_auto_gen_sep` and everything after it:
    new_b = b.split(_auto_gen_sep, 1)[0]

    # 5.  With `new_b`, add any editor-added hyperlinks to set:
    #     `editor_link_set`.
    for line in new_b.splitlines():
        match = _re_editor_added_hyperlink.match(line)
        if match is not None:
            editor_link_set.add(match[1])

    # 6.  If `_re_api_equals` present:
    #     - build list of symbols
    #     - compute list of hyperlinks from symbols
    #     - add hyperlinks to `genned_link_set` if not in `editor_link_set`.
    match = _re_api_equals.search(new_b)
    if match is not None:
        api_directives_found_count += 1
        comma_sep_list = match[1].strip()
        symbols = _re_multi_line_comma_sep.split(comma_sep_list)
        _add_exact_matches(symbols, genned_link_set, editor_link_set)

    # 7.  If `_re_api_startswith` present:
    #     - build list of symbols
    #     - compute list of hyperlinks from symbols
    #     - add hyperlinks to `genned_link_set` if not in `editor_link_set`.
    match = _re_api_startswith.search(new_b)
    if match is not None:
        api_directives_found_count += 1
        comma_sep_list = match[1].strip()
        symbols = _re_multi_line_comma_sep.split(comma_sep_list)
        _add_startswith_matches(symbols, genned_link_set, editor_link_set)

    # 8.  Lacking either of the above custom directives, use the lower-case
    #     stem of the filename and prefix it with "lv_" and try an
    #     "API startswith" with it.
    if api_directives_found_count == 0:
        base = os.path.basename(rst_file)
        stem = os.path.splitext(base)[0]
        _add_startswith_matches(['lv_' + stem], genned_link_set, editor_link_set)

    # 9.  If len(genned_link_set) > 0:
    #     - `C` = _auto_gen_sep + '\n\n' + `genned_link_set`
    #       (with a blank line between each).
    links_added_count = len(genned_link_set)

    if links_added_count > 0:
        c = '\n' + _auto_gen_sep + '\n\n'
        for link_name in sorted(genned_link_set, key=_hyperlink_sort_value):
            c += ':ref:`' + link_name + '`\n\n'

    # 10. Return tuple: (new_B, C, links_added_count).
    return new_b, c, links_added_count


def _process_one_file(rst_file: str):
    """
    Add applicable API hyperlinks to one file.

    Eligible
        An `.rst` file is eligible if it contains an API section at
        its end.  This can happen also in `index.rst` files when
        they head a single subject for which an API section is
        appropriate there and not in the sub-docs.  So `index.rst`
        files are included, whereas they were not included previously.

    Algorithm:
    ----------
    A.  Doc editors may have already added a set of hyperlinks of their
        own.  This routine takes note of and does not duplicate what is
        already there.

    B.  Doc editors may also have added specifications for this routine
        that look like this:

        .. API equals:  lv_obj_t, lv_arc_t, lv_barcode_t,
            lv_win_t, lv_list_t,
            lv_button_t

        .. API startswith:  lv_obj, lv_arc, lv_barcode,
            lv_win, lv_list,
            lv_button

        as directives for this routine to build a set of applicable
        hyperlinks.

    C.  Lacking any of the above custom directives, use the lower-case
        stem of the filename and prefix it with "lv_" and try an
        "API startswith" search with it.

    Any hyperlinks added by this routine are prefixed with the
    reStructuredText comment defined by the `_auto_gen_sep`
    variable, normally:

        .. Autogenerated

    If `rst_file` is eligible, edit after API section heading such that:
    - any editor-added hyperlinks are retained at the top of the list;
    - `_auto_gen_sep` (looked for in case a source file ends up having it;
      anything after it is replaced);
    - applicable hyperlinks added such that they do not repeat those
      added by editors of `.rst` file.

    Steps to Implement:
    -------------------
    0.  links_added_count = 0
    1.  Determine if eligible.
        - If not, skip to step 12.
        - If so, continue.
    2.  Split doc into 2 parts:
        A.  beginning through API section heading and subsequent
            whitespace including subsequent blank lines;
        B.  anything after that which may include editor-added hyperlinks.
    3-10. new_B, C, links_added_count = _process_end_of_eligible_doc()
    11. Write `A` + `new_B` + `C` back to `rst_file`.
    12. Return links_added_count.

    :param rst_file:  Full path to `.rst` file in question.
                        It may or may not be eligible.
    :return:          Number of links added.
    """
    links_added_count = 0

    with open(rst_file, 'rb') as f:
        try:
            rst_contents = f.read().decode('utf-8')
        except UnicodeDecodeError:
            announce(__file__, f'Error: UnicodeDecodeError in [{rst_file}].')
            raise

    eligible_match = _re_api_section_sep.search(rst_contents)

    if eligible_match is not None:
        # Eligible (API section found).
        i = eligible_match.end()
        # Split just after the API section heading + whitespace.
        a = rst_contents[:i]
        b = rst_contents[i:]
        new_b, c, links_added_count = _process_end_of_eligible_doc(b, rst_file)

        if links_added_count > 0:
            rst_contents = a + new_b + c

            with open(rst_file, 'wb') as f:
                f.write(rst_contents.encode('utf-8'))

    return links_added_count


def _build_one_local_dictionary(local_dict, remote_dict):
    """
    Remove '_' prefix in symbols beginning with '_lv' to make
    symbols like `lv_obj_t` actually connect with the struct
    in `lv_obj_private.h`, and not the typedef in `lv_types.h`.

    :param local_dict:   Local (adjusted) symbol dictionary
    :param remote_dict:  Dictionary from `doxygen_xml` module
    :return:             n/a
    """
    for symbol in remote_dict:
        # Note:  symbol `None` is actually a valid symbol in the
        # `enums` dictionary, containing all enum-value symbols
        # for enums without names.
        if symbol is None or not symbol.startswith('_lv'):
            loc_symbol = symbol
        else:
            # Remove '_' prefix.
            loc_symbol = symbol[1:]

        local_dict[loc_symbol] = remote_dict[symbol]


def _build_local_symbol_dictionaries():
    """
    Build "work-around" dictionaries so that a symbol like `lv_obj_t`
    actually connects with the struct in `lv_obj_private.h`, and not
    the typedef in `lv_types.h`.

    :return:  n/a
    """
    _build_one_local_dictionary(_defines, doxygen_xml.defines)
    _build_one_local_dictionary(_enums, doxygen_xml.enums)
    _build_one_local_dictionary(_variables, doxygen_xml.variables)
    _build_one_local_dictionary(_namespaces, doxygen_xml.namespaces)
    _build_one_local_dictionary(_structs, doxygen_xml.structures)
    _build_one_local_dictionary(_unions, doxygen_xml.unions)
    _build_one_local_dictionary(_typedefs, doxygen_xml.typedefs)
    _build_one_local_dictionary(_functions, doxygen_xml.functions)


def _add_hyperlinks_to_eligible_files(intermediate_dir: str,
                                      new_algorithm: bool,
                                      *doc_rel_paths: [str]):
    """
    Add applicable hyperlinks to eligible docs found joining
    `intermediate_dir` with each relative path in `doc_rel_paths`.

    See API-link algorithm documented under `_process_one_file()`.

    :param intermediate_dir:  Top directory where hyperlinks are to be added.
    :param doc_rel_paths:     Tuple of relative paths from `intermediate_dir` to
                                walk to find docs eligible for API hyperlinks.
    :return:
    """
    if new_algorithm:
        # Populate local symbol dictionary set with
        # symbols WITHOUT any '_' prefixes.
        _build_local_symbol_dictionaries()

        # Build `.rst` file list.
        file_list = []

        for rel_path in doc_rel_paths:
            top_dir = os.path.join(intermediate_dir, rel_path)
            for dir_bep, sub_dirs, files in os.walk(top_dir, topdown=False):
                for file in files:
                    if file.lower().endswith('.rst'):
                        file_list.append(os.path.join(dir_bep, file))

        total_eligible_doc_count = 0
        total_links_added_count = 0

        # For each `.rst` file, add appropriate API hyperlinks.
        for rst_file in file_list:
            links_added_count = _process_one_file(rst_file)

            if links_added_count > 0:
                total_links_added_count += links_added_count
                total_eligible_doc_count += 1
                # announce(__file__, f'Eligible doc: [{rst_file}].')

        announce(__file__, f'Docs eligible for API hyperlinks: {total_eligible_doc_count:>4}')
        announce(__file__, f'API hyperlinks added            : {total_links_added_count:>4}')
    else:
        for folder in doc_rel_paths:
            # Fetch a list of '.rst' files excluding 'index.rst'.
            rst_files = list(
                (os.path.splitext(item)[0], os.path.join(folder, item))
                for item in os.listdir(folder)
                if item.endswith('.rst') and 'index.rst' not in item
            )

            # For each .RST file in that directory...
            for stem, path in rst_files:
                # Start with an empty set.
                html_includes = set()

                # Build `html_includes` set as a list of tuples containing
                # (name, html_file).  Example:  "draw.rst" has `stem` == 'draw',
                # and generates a list of tuples from .H files where matching
                # C-code-element names were found.  Example:
                # {('lv_draw_line', 'draw\\lv_draw_line.html'),
                #  ('lv_draw_sdl', 'draw\\sdl\\lv_draw_sdl.html'),
                #  ('lv_draw_sw_blend_to_i1', 'draw\\sw\\blend\\lv_draw_sw_blend_to_i1.html'),
                #  etc.}
                for symbol_dict in (
                    doxygen_xml.defines,
                    doxygen_xml.enums,
                    doxygen_xml.variables,
                    doxygen_xml.namespaces,
                    doxygen_xml.structures,
                    doxygen_xml.unions,
                    doxygen_xml.typedefs,
                    doxygen_xml.functions
                ):
                    for key, obj in symbol_dict.items():
                        old_get_includes(stem, key, obj, html_includes)

                if html_includes:
                    # Convert `html_includes` set to a list of strings containing the
                    # Sphinx hyperlink syntax "link target names".  Example from above:
                    # [':ref:`lv_draw_line_h`\n',
                    #  ':ref:`lv_draw_sdl_h`\n',
                    #  ':ref:`lv_draw_sw_blend_to_i1_h`\n',
                    #  etc.]
                    html_includes = list(
                        ':ref:`{0}_h`\n'.format(inc)
                        for inc, _ in html_includes
                    )

                    # Convert that list to a single string of Sphinx hyperlink
                    # target names with blank lines between them.
                    # :ref:`lv_draw_line_h`
                    #
                    # :ref:`lv_draw_sdl_h`
                    #
                    # :ref:`lv_draw_sw_blend_to_i1_h`
                    #
                    # etc.
                    output = ('\n'.join(html_includes)) + '\n'

                    # Append that string to the  source .RST file being processed.
                    with open(path, 'rb') as f:
                        try:
                            data = f.read().decode('utf-8')
                        except UnicodeDecodeError:
                            print(path)
                            raise

                    data = data.split(_auto_gen_sep, 1)[0]

                    data += f'{_auto_gen_sep}\n\n'
                    data += output

                    with open(path, 'wb') as f:
                        f.write(data.encode('utf-8'))


def _extended_stem_from_path(path: str) -> str:
    filename = os.path.basename(path)
    stem, ext = os.path.splitext(filename)
    return stem + '_' + ext[1:]  # Remove leading '.' from ext.


def _create_rst_files_for_dir(src_root_dir_len: int,
                              src_dir_bep: str,
                              elig_h_files: [str],
                              elig_sub_dirs: [str],
                              out_root_dir: str):
    """
    - Create `index.rst` file and add its top section.
    - For each file in `elig_h_files`:
      - Create one `.rst` file.
      - Add reference to it in `index.rst`.
    - For each subdir in `elig_sub_dirs`:
        - add reference "sub_dir_name/index" in `index.rst`.

    :param src_root_dir_len:  Length of source-root path string, used with `out_root_dir` to build paths
                              Example:  17
    :param src_dir_bep:       Directory currently *being processed*
                              Example:  '/path/to/lvgl/src/draw/sw/blend'
    :param elig_h_files:      Eligible `.h*` files directly contained in `src_dir_bep`
                              Example:  [] or
                              [
                                  '/path/to/lvgl/src/draw/sw/blend/lv_draw_sw_blend.h',
                                  '/path/to/lvgl/src/draw/sw/blend/lv_draw_sw_blend_private.h',
                                  '/path/to/lvgl/src/draw/sw/blend/lv_draw_sw_blend_to_al88.h',
                                  '/path/to/lvgl/src/draw/sw/blend/lv_draw_sw_blend_to_argb8888.h',
                                  '/path/to/lvgl/src/draw/sw/blend/lv_draw_sw_blend_to_argb8888_premultiplied.h',
                                  '/path/to/lvgl/src/draw/sw/blend/lv_draw_sw_blend_to_i1.h',
                                  '/path/to/lvgl/src/draw/sw/blend/lv_draw_sw_blend_to_l8.h',
                                  '/path/to/lvgl/src/draw/sw/blend/lv_draw_sw_blend_to_rgb565.h',
                                  '/path/to/lvgl/src/draw/sw/blend/lv_draw_sw_blend_to_rgb565_swapped.h',
                                  '/path/to/lvgl/src/draw/sw/blend/lv_draw_sw_blend_to_rgb888.h'
                              ]
    :param elig_sub_dirs:     List of sub-dirs that contained eligible `.h*` files
                              Example:  [] or
                              [
                                  '/path/to/lvgl/src/draw/sw/blend/arm2d',
                                  '/path/to/lvgl/src/draw/sw/blend/helium',
                                  '/path/to/lvgl/src/draw/sw/blend/neon'
                              ]
    :param out_root_dir:      Root of output directory, used with to build paths.
                              Example:  '/path/to/lvgl/src/docs/intermediate/API'
    :return:                  n/a
    """
    indent = '    '
    # Keep only sub-path under 'lvgl/src/'.
    # Example:  'draw/sw/blend'
    sub_path = src_dir_bep[src_root_dir_len:]
    # Build full path to output directory for 'index.rst' file.
    # Example:  '/path/to/lvgl/src/docs/intermediate/API/draw/sw/blend'
    out_dir = str(os.path.join(out_root_dir, sub_path))

    # Ensure dir exists.  Multiple dirs MAY have to be created
    # since `.rst` files are created in bottom-up sequence.
    if not os.path.isdir(out_dir):
        os.makedirs(out_dir)

    # For top-level directory only... (the last index.rst created,
    # since they are created in bottom-up sequence)
    if len(sub_path) == 0 and out_dir.endswith(os.sep):
        # Trim trailing slash from `out_dir`.
        out_dir = out_dir[:-1]

    # index.rst
    with open(os.path.join(out_dir, 'index.rst'), 'w') as f:
        subdir_stem = os.path.split(out_dir)[-1]
        section_line = (rst_section_line_char * len(subdir_stem)) + '\n'
        f.write(section_line)
        f.write(subdir_stem + '\n')
        f.write(section_line)
        f.write('\n')
        f.write('.. toctree::\n    :maxdepth: 1\n    :class:    toctree-1-deep\n\n')

        # One entry per `.rst` file
        for h_file in elig_h_files:
            extended_stem = _extended_stem_from_path(h_file)
            f.write(indent + extended_stem + '\n')

        # One entry per eligible subdirectory.
        for sub_dir in elig_sub_dirs:
            stem = os.path.split(sub_dir)[-1]
            f.write(indent + stem + '/index\n')

    # One .rst file per h_file
    for h_file in elig_h_files:
        filename = os.path.basename(h_file)
        extended_stem = _extended_stem_from_path(h_file)
        rst_file = os.path.join(out_dir, extended_stem + '.rst')
        html_file = os.path.join(sub_path, extended_stem + '.html')
        old_html_files[extended_stem] = html_file

        with open(rst_file, 'w') as f:
            # Sphinx link target.
            # The link name should look like this:  draw/sw/blend/lv_draw_sw_blend.h
            # including when running under Windows.
            if on_windows:
                link_name_path = sub_path.replace(os.sep, '/')
            else:
                link_name_path = sub_path

            if len(link_name_path) > 0:
                link_name = link_name_path + '/' + filename
            else:
                link_name = filename

            line = f'.. _{link_name}:\n\n'

            f.write(line)
            # Doc title.
            section_line = (rst_section_line_char * len(filename)) + '\n'
            f.write(section_line)
            f.write(filename + '\n')
            f.write(section_line)
            f.write('\n')
            # Content for `breathe`.
            f.write(f'.. doxygenfile:: {filename}\n')
            f.write('    :project: lvgl\n\n')


def _recursively_create_api_rst_files(depth: int,
                                      src_root_len: int,
                                      src_dir_bep: str,
                                      out_root_dir: str) -> int:
    """
    Create `.rst` files for the eligible C source files found in `src_dir_bep` and
    recursively for subdirectories below it.  ("bep" = being processed.)

    Eligible
        An input file (e.g. `.h*`) file is eligible if Doxygen generated
        documentation for it.  The combination of these configuration items in
        the Doxyfile:

        - INPUT
        - FILE_PATTERNS
        - EXCLUDE
        - EXCLUDE_PATTERNS

        controls the files Doxygen processes.  Files not processed are not eligible.

    Whether a subdirectory is eligible to be included in an `index.rst`
    file depends upon whether any eligible files were recursively
    found within it.  And that isn't known until this function finishes
    (recursively) processing a directory and returns the number of
    eligible `.h*` files found in its subdirectory tree.  Thus, the steps
    taken within are:

    - Discover all eligible `.h*` files directly contained in `src_dir_bep`.
    - Recursively do the same for each subdirectory, adding the returned
      count of eligible `.h*` files to the sum (`elig_h_file_count`).
    - If `elig_h_file_count > 0`:
        - call _create_rst_files_for_dir() to generate appropriate
          `.rst` files for this directory.
    - Return `elig_h_file_count`.

    Once we have accumulated this information, then we can generate
    all the `.rst` files for the current directory without any further
    directory-tree walking.

    :param depth:         Only used for testing/debugging
    :param src_root_len:  Length of source-root path
    :param src_dir_bep:   Source directory *being processed*
    :param out_root_dir:  Output root directory (used to build output paths)
    :return:              Number of `.h*` files encountered (so caller knows
                            whether that directory recursively held any
                            eligible `.h*` files, to know whether to include
                            "subdir/index" in caller's local `index.rst` file).
    """
    elig_h_files = []
    sub_dirs = []
    elig_sub_dirs = []
    elig_h_file_count = 0

    # For each "thing" found in `src_dir_bep`, build lists:
    # `elig_sub_dirs` and `elig_h_files`.
    # By design change, we are including files with 'private'
    # in their names.  Reason:  advanced users who need to use
    # the structs defined within will need the documentation
    # in those API pages!
    for dir_item in os.listdir(src_dir_bep):
        path_bep = os.path.join(src_dir_bep, dir_item)
        if os.path.isdir(path_bep):
            sub_dirs.append(path_bep)         # Add to sub-dir list.
        else:
            _, ext = os.path.splitext(dir_item)
            # `ext` is converted to lower case so that any incidental case change
            # in the extension on Windows will not break this algorithm.
            if ext.lower() in doxy_src_file_ext_list:
                eligible = (dir_item in doxygen_xml.files
                        and dir_item not in _cfg_exclude_list)

                if eligible:
                    elig_h_files.append(path_bep)  # Add file to list.
                    elig_h_file_count += 1

    # For each subdir...
    for sub_dir in sub_dirs:
        subdir_eligible_h_file_count = \
            _recursively_create_api_rst_files(depth + 1,
                                              src_root_len,
                                              sub_dir,
                                              out_root_dir)

        if subdir_eligible_h_file_count > 0:
            elig_sub_dirs.append(sub_dir)
            elig_h_file_count += subdir_eligible_h_file_count

    if elig_h_file_count > 0:
        # Sort both lists.
        # Evidently the Linux-Python's implementation of `listdir()` does not
        # automatically produce a sorted list.
        elig_sub_dirs.sort()
        elig_h_files.sort()

        # Create index.rst plus .RST files for any .H* file directly in dir.
        _create_rst_files_for_dir(src_root_len,
                                  src_dir_bep,
                                  elig_h_files,
                                  elig_sub_dirs,
                                  out_root_dir)

    return elig_h_file_count


def create_api_rst_files(src_root_dir: str, out_root_dir: str):
    """
    Create `.rst` files for API pages based on the `.h*` files found
    in a tree-walk of `a_src_root` and the current contents of the
    `doxygen_xml.files` dictionary (used to filter out `.h*` files that
    Doxygen generated no documentation for).  Output the `.rst` files
    into `out_root_dir` mirroring the `a_src_root` directory structure.

    :param src_root_dir:  root source directory to walk
    :param out_root_dir:  output directory
    :return:              n/a
    """
    src_root_len = len(src_root_dir) + 1
    _recursively_create_api_rst_files(0, src_root_len, src_root_dir, out_root_dir)


def build_api_docs(lvgl_src_dir, intermediate_dir, doxyfile_src_file, *doc_rel_paths):
    """
    - Prep and run Doxygen, outputting XML.
    - Load that XML in a form that can quickly tie C symbols to the
      source files they came from.
    - Generate API page `.rst` files for source files Doxygen generated
      documentation for.
    - Add hyperlinks to these API pages for `.rst` files in `*doc_rel_paths`
      that are eligible.

    :param lvgl_src_dir:       Path to LVGL src directory
    :param intermediate_dir:   Path to intermediate dir being built
    :param doxyfile_src_file:  Full path to src doxygen configuration file
    :param doc_rel_paths:      List of relative paths from `intermediate_dir` to
                                 walk to find docs eligible for API hyperlinks.
    """
    # ---------------------------------------------------------------------
    # - Generate Doxyfile replacing tokens,
    # - run Doxygen generating XML, and
    # - load the generated XML from Doxygen output.
    # ---------------------------------------------------------------------
    doxygen_xml.EMIT_WARNINGS = EMIT_WARNINGS

    xml_parser = doxygen_xml.DoxygenXml(lvgl_src_dir,
                                        intermediate_dir,
                                        doxyfile_src_file,
                                        silent_mode=False
                                        )

    # ---------------------------------------------------------------------
    # Generate .RST files for API pages.
    # ---------------------------------------------------------------------
    announce(__file__, "Generating API documentation .RST files...")
    api_out_root_dir = os.path.join(intermediate_dir, 'API')
    create_api_rst_files(lvgl_src_dir, api_out_root_dir)

    # ---------------------------------------------------------------------
    # For each directory entry in `doc_rel_paths` array...
    # - add API hyperlinks to .RST files in the directories in passed array.
    # ---------------------------------------------------------------------
    announce(__file__, "Adding API-page hyperlinks to source docs...")
    _add_hyperlinks_to_eligible_files(intermediate_dir,
                                      True,
                                      *doc_rel_paths)
