"""Experimental docutils writers for HTML5 handling Sphinx's custom nodes."""

from __future__ import annotations

import posixpath
import re
import urllib.parse
from typing import TYPE_CHECKING

from docutils import nodes
from docutils.writers.html5_polyglot import HTMLTranslator as BaseTranslator

from sphinx import addnodes
from sphinx.locale import _, __, admonitionlabels
from sphinx.util import logging
from sphinx.util.docutils import SphinxTranslator
from sphinx.util.images import get_image_size

if TYPE_CHECKING:
    from collections.abc import Callable

    from docutils.nodes import Element, Node, Text

    from sphinx.builders import Builder
    from sphinx.builders.html import StandaloneHTMLBuilder


logger = logging.getLogger(__name__)

# A good overview of the purpose behind these classes can be found here:
# https://www.arnebrodowski.de/blog/write-your-own-restructuredtext-writer.html


def multiply_length(length: str, scale: int) -> str:
    """Multiply *length* (width or height) by *scale*."""
    matched = re.match(r'^(\d*\.?\d*)\s*(\S*)$', length)
    if not matched:
        return length
    if scale == 100:
        return length
    amount, unit = matched.groups()
    result = float(amount) * scale / 100
    return f'{int(result)}{unit}'


class HTML5Translator(SphinxTranslator, BaseTranslator):
    """Our custom HTML translator."""

    builder: StandaloneHTMLBuilder
    # Override docutils.writers.html5_polyglot:HTMLTranslator
    # otherwise, nodes like <inline classes="s">...</inline> will be
    # converted to <s>...</s> by `visit_inline`.
    supported_inline_tags: set[str] = set()

    def __init__(self, document: nodes.document, builder: Builder) -> None:
        super().__init__(document, builder)

        self.highlighter = self.builder.highlighter
        self.docnames = [self.builder.current_docname]  # for singlehtml builder
        self.protect_literal_text = 0
        self.secnumber_suffix = self.config.html_secnumber_suffix
        self.param_separator = ''
        self.optional_param_level = 0
        self._table_row_indices = [0]
        self._fieldlist_row_indices = [0]
        self.required_params_left = 0
        self._has_maths_elements: bool = False

    def visit_start_of_file(self, node: Element) -> None:
        # only occurs in the single-file builder
        self.docnames.append(node['docname'])
        self.body.append('<span id="document-%s"></span>' % node['docname'])

    def depart_start_of_file(self, node: Element) -> None:
        self.docnames.pop()

    #############################################################
    # Domain-specific object descriptions
    #############################################################

    # Top-level nodes for descriptions
    ##################################

    def visit_desc(self, node: Element) -> None:
        self.body.append(self.starttag(node, 'dl'))

    def depart_desc(self, node: Element) -> None:
        self.body.append('</dl>\n\n')

    def visit_desc_signature(self, node: Element) -> None:
        # the id is set automatically
        self.body.append(self.starttag(node, 'dt'))
        self.protect_literal_text += 1

    def depart_desc_signature(self, node: Element) -> None:
        self.protect_literal_text -= 1
        if not node.get('is_multiline'):
            self.add_permalink_ref(node, _('Link to this definition'))
        self.body.append('</dt>\n')

    def visit_desc_signature_line(self, node: Element) -> None:
        pass

    def depart_desc_signature_line(self, node: Element) -> None:
        if node.get('add_permalink'):
            # the permalink info is on the parent desc_signature node
            self.add_permalink_ref(node.parent, _('Link to this definition'))
        self.body.append('<br />')

    def visit_desc_content(self, node: Element) -> None:
        self.body.append(self.starttag(node, 'dd', ''))

    def depart_desc_content(self, node: Element) -> None:
        self.body.append('</dd>')

    def visit_desc_inline(self, node: Element) -> None:
        self.body.append(self.starttag(node, 'span', ''))

    def depart_desc_inline(self, node: Element) -> None:
        self.body.append('</span>')

    # Nodes for high-level structure in signatures
    ##############################################

    def visit_desc_name(self, node: Element) -> None:
        self.body.append(self.starttag(node, 'span', ''))

    def depart_desc_name(self, node: Element) -> None:
        self.body.append('</span>')

    def visit_desc_addname(self, node: Element) -> None:
        self.body.append(self.starttag(node, 'span', ''))

    def depart_desc_addname(self, node: Element) -> None:
        self.body.append('</span>')

    def visit_desc_type(self, node: Element) -> None:
        pass

    def depart_desc_type(self, node: Element) -> None:
        pass

    def visit_desc_returns(self, node: Element) -> None:
        self.body.append(' <span class="sig-return">')
        self.body.append('<span class="sig-return-icon">&#x2192;</span>')
        self.body.append(' <span class="sig-return-typehint">')

    def depart_desc_returns(self, node: Element) -> None:
        self.body.append('</span></span>')

    def _visit_sig_parameter_list(
        self,
        node: Element,
        parameter_group: type[Element],
        sig_open_paren: str,
        sig_close_paren: str,
    ) -> None:
        """Visit a signature parameters or type parameters list.

        The *parameter_group* value is the type of child nodes acting as required parameters
        or as a set of contiguous optional parameters.
        """
        self.body.append(f'<span class="sig-paren">{sig_open_paren}</span>')
        self.is_first_param = True
        self.optional_param_level = 0
        self.params_left_at_level = 0
        self.param_group_index = 0
        # Counts as what we call a parameter group either a required parameter, or a
        # set of contiguous optional ones.
        self.list_is_required_param = [
            isinstance(c, parameter_group) for c in node.children
        ]
        # How many required parameters are left.
        self.required_params_left = sum(self.list_is_required_param)
        self.param_separator = node.child_text_separator
        self.multi_line_parameter_list = node.get('multi_line_parameter_list', False)
        self.trailing_comma = node.get('multi_line_trailing_comma', False)
        if self.multi_line_parameter_list:
            self.body.append('\n\n')
            self.body.append(self.starttag(node, 'dl'))
            self.param_separator = self.param_separator.rstrip()
        self.context.append(sig_close_paren)

    def _depart_sig_parameter_list(self, node: Element) -> None:
        if node.get('multi_line_parameter_list'):
            self.body.append('</dl>\n\n')
        sig_close_paren = self.context.pop()
        self.body.append(f'<span class="sig-paren">{sig_close_paren}</span>')

    def visit_desc_parameterlist(self, node: Element) -> None:
        self._visit_sig_parameter_list(node, addnodes.desc_parameter, '(', ')')

    def depart_desc_parameterlist(self, node: Element) -> None:
        self._depart_sig_parameter_list(node)

    def visit_desc_type_parameter_list(self, node: Element) -> None:
        self._visit_sig_parameter_list(node, addnodes.desc_type_parameter, '[', ']')

    def depart_desc_type_parameter_list(self, node: Element) -> None:
        self._depart_sig_parameter_list(node)

    # If required parameters are still to come, then put the comma after
    # the parameter.  Otherwise, put the comma before.  This ensures that
    # signatures like the following render correctly:
    #
    #     foo([a, ]b, c[, d])
    #
    # See: https://github.com/sphinx-doc/sphinx/issues/1001
    def visit_desc_parameter(self, node: Element) -> None:
        on_separate_line = self.multi_line_parameter_list
        if on_separate_line and not (
            self.is_first_param and self.optional_param_level > 0
        ):
            self.body.append(self.starttag(node, 'dd', ''))
        if self.is_first_param:
            self.is_first_param = False
        elif not on_separate_line and not self.required_params_left:
            self.body.append(self.param_separator)
        if self.optional_param_level == 0:
            self.required_params_left -= 1
        else:
            self.params_left_at_level -= 1
        if not node.hasattr('noemph'):
            self.body.append('<em class="sig-param">')

    def depart_desc_parameter(self, node: Element) -> None:
        if not node.hasattr('noemph'):
            self.body.append('</em>')
        is_required = self.list_is_required_param[self.param_group_index]
        if self.multi_line_parameter_list:
            len_lirp = len(self.list_is_required_param)
            is_last_group = self.param_group_index + 1 == len_lirp
            next_is_required = (
                not is_last_group
                and self.list_is_required_param[self.param_group_index + 1]
            )
            opt_param_left_at_level = self.params_left_at_level > 0
            if (
                opt_param_left_at_level
                or is_required
                and (is_last_group or next_is_required)
            ):
                if not is_last_group or opt_param_left_at_level or self.trailing_comma:
                    self.body.append(self.param_separator)
                self.body.append('</dd>\n')

        elif self.required_params_left:
            self.body.append(self.param_separator)

        if is_required:
            self.param_group_index += 1

    def visit_desc_type_parameter(self, node: Element) -> None:
        self.visit_desc_parameter(node)

    def depart_desc_type_parameter(self, node: Element) -> None:
        self.depart_desc_parameter(node)

    def visit_desc_optional(self, node: Element) -> None:
        self.params_left_at_level = sum(
            isinstance(c, addnodes.desc_parameter) for c in node.children
        )
        self.optional_param_level += 1
        self.max_optional_param_level = self.optional_param_level
        if self.multi_line_parameter_list:
            # If the first parameter is optional, start a new line and open the bracket.
            if self.is_first_param:
                self.body.append(self.starttag(node, 'dd', ''))
                self.body.append('<span class="optional">[</span>')
            # Else, if there remains at least one required parameter, append the
            # parameter separator, open a new bracket, and end the line.
            elif self.required_params_left:
                self.body.append(self.param_separator)
                self.body.append('<span class="optional">[</span>')
                self.body.append('</dd>\n')
            # Else, open a new bracket, append the parameter separator,
            # and end the line.
            else:
                self.body.append('<span class="optional">[</span>')
                self.body.append(self.param_separator)
                self.body.append('</dd>\n')
        else:
            self.body.append('<span class="optional">[</span>')

    def depart_desc_optional(self, node: Element) -> None:
        self.optional_param_level -= 1
        level = self.optional_param_level
        if self.multi_line_parameter_list:
            max_level = self.max_optional_param_level
            len_lirp = len(self.list_is_required_param)
            is_last_group = self.param_group_index + 1 == len_lirp
            # If it's the first time we go down one level, add the separator before the
            # bracket, except if this is the last parameter and the parameter list
            # should not feature a trailing comma.
            if level == max_level - 1 and (
                not is_last_group or level > 0 or self.trailing_comma
            ):
                self.body.append(self.param_separator)
            self.body.append('<span class="optional">]</span>')
            # End the line if we have just closed the last bracket of this
            # optional parameter group.
            if level == 0:
                self.body.append('</dd>\n')
        else:
            self.body.append('<span class="optional">]</span>')
        if level == 0:
            self.param_group_index += 1

    def visit_desc_annotation(self, node: Element) -> None:
        self.body.append(self.starttag(node, 'span', '', CLASS='property'))

    def depart_desc_annotation(self, node: Element) -> None:
        self.body.append('</span>')

    ##############################################

    def visit_versionmodified(self, node: Element) -> None:
        self.body.append(self.starttag(node, 'div', CLASS=node['type']))

    def depart_versionmodified(self, node: Element) -> None:
        self.body.append('</div>\n')

    # overwritten
    def visit_reference(self, node: Element) -> None:
        atts = {'class': 'reference'}
        if node.get('internal') or 'refuri' not in node:
            atts['class'] += ' internal'
        else:
            atts['class'] += ' external'
        if 'refuri' in node:
            atts['href'] = node['refuri'] or '#'
            if (
                self.settings.cloak_email_addresses
                and atts['href'].startswith('mailto:')
            ):  # fmt: skip
                atts['href'] = self.cloak_mailto(atts['href'])
                self.in_mailto = True
        else:
            assert 'refid' in node, (
                'References must have "refuri" or "refid" attribute.'
            )
            atts['href'] = '#' + node['refid']
        if not isinstance(node.parent, nodes.TextElement):
            assert len(node) == 1 and isinstance(node[0], nodes.image)  # NoQA: PT018
            atts['class'] += ' image-reference'
        if 'reftitle' in node:
            atts['title'] = node['reftitle']
        if 'target' in node:
            atts['target'] = node['target']
        if 'rel' in node:
            atts['rel'] = node['rel']
        # TODO: TYPING: Upstream docutils should type HTMLTranslator.starttag()
        #       to accept **attributes; current stub treats the 4th parameter
        #       as empty: bool.
        self.body.append(self.starttag(node, 'a', '', **atts))  # type: ignore[arg-type]

        if node.get('secnumber'):
            self.body.append(
                ('%s' + self.secnumber_suffix) % '.'.join(map(str, node['secnumber']))
            )

    def visit_number_reference(self, node: Element) -> None:
        self.visit_reference(node)

    def depart_number_reference(self, node: nodes.reference) -> None:
        self.depart_reference(node)

    # overwritten -- we don't want source comments to show up in the HTML
    def visit_comment(
        self, node: nodes.comment, sub: Callable[[str, str], str] | None = None
    ) -> None:
        raise nodes.SkipNode

    # overwritten
    def visit_admonition(self, node: Element, name: str = '') -> None:
        attributes = {}
        tag_name = 'div'
        if collapsible := node.get('collapsible'):
            tag_name = 'details'
            if collapsible == 'open':
                attributes['open'] = 'open'
        # TODO: TYPING: Upstream docutils should type HTMLTranslator.starttag()
        #       to accept **attributes; current stub treats the 4th parameter
        #       as empty: bool.
        self.body.append(
            self.starttag(
                node,
                tag_name,
                CLASS=f'admonition {name}',
                **attributes,  # type: ignore[arg-type]
            )
        )
        self.context.append(f'</{tag_name}>\n')
        if name:
            node.insert(0, nodes.title(name, admonitionlabels[name]))

    def depart_admonition(self, node: Element | None = None) -> None:
        self.body.append(self.context.pop())

    def visit_seealso(self, node: Element) -> None:
        self.visit_admonition(node, 'seealso')

    def depart_seealso(self, node: Element) -> None:
        self.depart_admonition(node)

    def get_secnumber(self, node: Element) -> tuple[int, ...] | None:
        if node.get('secnumber'):
            return node['secnumber']

        if isinstance(node.parent, nodes.section):
            if self.builder.name == 'singlehtml':
                docname = self.docnames[-1]
                anchorname = f'{docname}/#{node.parent["ids"][0]}'
                if anchorname not in self.builder.secnumbers:
                    # try first heading which has no anchor
                    anchorname = f'{docname}/'
            else:
                anchorname = '#' + node.parent['ids'][0]
                if anchorname not in self.builder.secnumbers:
                    anchorname = ''  # try first heading which has no anchor

            if self.builder.secnumbers.get(anchorname):
                return self.builder.secnumbers[anchorname]

        return None

    def add_secnumber(self, node: Element) -> None:
        secnumber = self.get_secnumber(node)
        if secnumber:
            self.body.append(
                '<span class="section-number">%s</span>'
                % ('.'.join(map(str, secnumber)) + self.secnumber_suffix)
            )

    def add_fignumber(self, node: Element) -> None:
        def append_fignumber(figtype: str, figure_id: str) -> None:
            if self.builder.name == 'singlehtml':
                key = f'{self.docnames[-1]}/{figtype}'
            else:
                key = figtype

            if figure_id in self.builder.fignumbers.get(key, {}):
                self.body.append('<span class="caption-number">')
                prefix = self.config.numfig_format.get(figtype)
                if prefix is None:
                    msg = __('numfig_format is not defined for %s') % figtype
                    logger.warning(msg)
                else:
                    numbers = self.builder.fignumbers[key][figure_id]
                    self.body.append(prefix % '.'.join(map(str, numbers)) + ' ')
                    self.body.append('</span>')

        figtype = self._domains.standard_domain.get_enumerable_node_type(node)
        if figtype:
            if len(node['ids']) == 0:
                msg = __('Any IDs not assigned for %s node') % node.tagname
                logger.warning(msg, location=node)
            else:
                append_fignumber(figtype, node['ids'][0])

    def add_permalink_ref(self, node: Element, title: str) -> None:
        icon = self.config.html_permalinks_icon
        if node['ids'] and self.config.html_permalinks and self.builder.add_permalinks:
            self.body.append(
                f'<a class="headerlink" href="#{node["ids"][0]}" title="{title}">{icon}</a>',
            )

    # overwritten
    def visit_bullet_list(self, node: nodes.bullet_list) -> None:
        if len(node) == 1 and isinstance(node[0], addnodes.toctree):
            # avoid emitting empty <ul></ul>
            raise nodes.SkipNode
        super().visit_bullet_list(node)

    # overwritten
    def visit_definition(self, node: Element) -> None:
        # don't insert </dt> here.
        self.body.append(self.starttag(node, 'dd', ''))

    # overwritten
    def depart_definition(self, node: Element) -> None:
        self.body.append('</dd>\n')

    # overwritten
    def visit_classifier(self, node: Element) -> None:
        self.body.append(self.starttag(node, 'span', '', CLASS='classifier'))

    # overwritten
    def depart_classifier(self, node: Element) -> None:
        self.body.append('</span>')

        next_node: Node | None = node.next_node(descend=False, siblings=True)
        if not isinstance(next_node, nodes.classifier):
            # close `<dt>` tag at the tail of classifiers
            self.body.append('</dt>')

    # overwritten
    def visit_term(self, node: Element) -> None:
        self.body.append(self.starttag(node, 'dt', ''))

    # overwritten
    def depart_term(self, node: Element) -> None:
        next_node: Node | None = node.next_node(descend=False, siblings=True)
        if isinstance(next_node, nodes.classifier):
            # Leave the end tag to `self.depart_classifier()`, in case
            # there's a classifier.
            pass
        else:
            if isinstance(node.parent.parent.parent, addnodes.glossary):
                # add permalink if glossary terms
                self.add_permalink_ref(node, _('Link to this term'))

            self.body.append('</dt>')

    # overwritten
    def visit_title(self, node: nodes.title) -> None:
        if (
            isinstance(node.parent, addnodes.compact_paragraph)
            and node.parent.get('toctree')
        ):  # fmt: skip
            self.body.append(
                self.starttag(node, 'p', '', CLASS='caption', ROLE='heading')
            )
            self.body.append('<span class="caption-text">')
            self.context.append('</span></p>\n')
        elif (
            isinstance(node.parent, nodes.Admonition)
            and isinstance(node.parent, nodes.Element)
            and 'collapsible' in node.parent
        ):
            self.body.append(
                self.starttag(node, 'summary', '', CLASS='admonition-title')
            )
            self.context.append('</summary>\n')
        else:
            super().visit_title(node)
        self.add_secnumber(node)
        self.add_fignumber(node.parent)
        if isinstance(node.parent, nodes.table):
            self.body.append('<span class="caption-text">')
        # Partially revert https://sourceforge.net/p/docutils/code/9562/
        if (
            isinstance(node.parent, nodes.topic)
            and self.settings.toc_backlinks
            and 'contents' in node.parent['classes']
            and self.body[-1].startswith('<a ')
            # TODO: only remove for EPUB
        ):
            # remove <a class="reference internal" href="#top">
            self.body.pop()
            self.context[-1] = '</p>\n'

    def depart_title(self, node: nodes.title) -> None:
        close_tag = self.context[-1]
        if (
            self.config.html_permalinks
            and self.builder.add_permalinks
            and node.parent.hasattr('ids')
            and node.parent['ids']
        ):
            # add permalink anchor
            if close_tag.startswith('</h'):
                self.add_permalink_ref(node.parent, _('Link to this heading'))
            elif close_tag.startswith('</a></h'):
                self.body.append(
                    '</a><a class="headerlink" href="#%s" ' % node.parent['ids'][0]
                    + 'title="{}">{}'.format(
                        _('Link to this heading'), self.config.html_permalinks_icon
                    )
                )
            elif isinstance(node.parent, nodes.table):
                self.body.append('</span>')
                self.add_permalink_ref(node.parent, _('Link to this table'))
        elif isinstance(node.parent, nodes.table):
            self.body.append('</span>')

        super().depart_title(node)

    # overwritten
    def visit_rubric(self, node: nodes.rubric) -> None:
        if 'heading-level' in node:
            level = node['heading-level']
            if level in {1, 2, 3, 4, 5, 6}:
                self.body.append(self.starttag(node, f'h{level}', '', CLASS='rubric'))
            else:
                logger.warning(
                    __('unsupported rubric heading level: %s'),
                    level,
                    type='html',
                    location=node,
                )
                super().visit_rubric(node)
        else:
            super().visit_rubric(node)

    # overwritten
    def depart_rubric(self, node: nodes.rubric) -> None:
        if (level := node.get('heading-level')) in {1, 2, 3, 4, 5, 6}:
            self.body.append(f'</h{level}>\n')
        else:
            super().depart_rubric(node)

    # overwritten
    def visit_literal_block(self, node: nodes.literal_block) -> None:
        if node.rawsource != node.astext():
            # most probably a parsed-literal block -- don't highlight
            return super().visit_literal_block(node)

        lang = node.get('language', 'default')
        linenos = node.get('linenos', False)
        highlight_args = node.get('highlight_args', {})
        highlight_args['force'] = node.get('force', False)
        opts = self.config.highlight_options.get(lang, {})

        if linenos and self.config.html_codeblock_linenos_style:
            linenos = self.config.html_codeblock_linenos_style

        highlighted = self.highlighter.highlight_block(
            node.rawsource,
            lang,
            opts=opts,
            linenos=linenos,
            location=node,
            **highlight_args,
        )
        starttag = self.starttag(
            node, 'div', suffix='', CLASS='highlight-%s notranslate' % lang
        )
        self.body.append(starttag + highlighted + '</div>\n')
        raise nodes.SkipNode

    def visit_caption(self, node: nodes.caption) -> None:
        if (
            isinstance(node.parent, nodes.container)
            and node.parent.get('literal_block')
        ):  # fmt: skip
            self.body.append('<div class="code-block-caption">')
        else:
            super().visit_caption(node)
        self.add_fignumber(node.parent)
        self.body.append(self.starttag(node, 'span', '', CLASS='caption-text'))

    def depart_caption(self, node: nodes.caption) -> None:
        self.body.append('</span>')

        # append permalink if available
        if (
            isinstance(node.parent, nodes.container)
            and node.parent.get('literal_block')
        ):  # fmt: skip
            self.add_permalink_ref(node.parent, _('Link to this code'))
        elif isinstance(node.parent, nodes.figure):
            self.add_permalink_ref(node.parent, _('Link to this image'))
        elif node.parent.get('toctree'):
            self.add_permalink_ref(node.parent.parent, _('Link to this toctree'))

        if (
            isinstance(node.parent, nodes.container)
            and node.parent.get('literal_block')
        ):  # fmt: skip
            self.body.append('</div>\n')
        else:
            super().depart_caption(node)

    def visit_doctest_block(self, node: Element) -> None:
        self.visit_literal_block(node)  # type: ignore[arg-type]

    # overwritten to add the <div> (for XHTML compliance)
    def visit_block_quote(self, node: Element) -> None:
        self.body.append(self.starttag(node, 'blockquote') + '<div>')

    def depart_block_quote(self, node: Element) -> None:
        self.body.append('</div></blockquote>\n')

    # overwritten
    def visit_literal(self, node: Element) -> None:
        if 'kbd' in node['classes']:
            self.body.append(
                self.starttag(node, 'kbd', '', CLASS='docutils literal notranslate')
            )
            return
        lang = node.get('language', None)
        if 'code' not in node['classes'] or not lang:
            self.body.append(
                self.starttag(node, 'code', '', CLASS='docutils literal notranslate')
            )
            self.protect_literal_text += 1
            return

        opts = self.config.highlight_options.get(lang, {})
        highlighted = self.highlighter.highlight_block(
            node.astext(), lang, opts=opts, location=node, nowrap=True
        )
        starttag = self.starttag(
            node,
            'code',
            suffix='',
            CLASS='docutils literal highlight highlight-%s' % lang,
        )
        self.body.append(starttag + highlighted.strip() + '</code>')
        raise nodes.SkipNode

    def depart_literal(self, node: Element) -> None:
        if 'kbd' in node['classes']:
            self.body.append('</kbd>')
        else:
            self.protect_literal_text -= 1
            self.body.append('</code>')

    def visit_productionlist(self, node: Element) -> None:
        self.body.append(self.starttag(node, 'pre'))

    def depart_productionlist(self, node: Element) -> None:
        self.body.append('</pre>\n')

    def visit_production(self, node: Element) -> None:
        pass

    def depart_production(self, node: Element) -> None:
        pass

    def visit_centered(self, node: Element) -> None:
        self.body.append(self.starttag(node, 'p', CLASS='centered') + '<strong>')

    def depart_centered(self, node: Element) -> None:
        self.body.append('</strong></p>')

    def visit_compact_paragraph(self, node: Element) -> None:
        pass

    def depart_compact_paragraph(self, node: Element) -> None:
        pass

    def visit_download_reference(self, node: Element) -> None:
        atts = {'class': 'reference download', 'download': ''}

        if not self.builder.download_support:
            self.context.append('')
        elif 'refuri' in node:
            atts['class'] += ' external'
            atts['href'] = node['refuri']
            # TODO: TYPING: Upstream docutils should type HTMLTranslator.starttag()
            #       to accept **attributes; current stub treats the 4th parameter
            #       as empty: bool.
            self.body.append(self.starttag(node, 'a', '', **atts))  # type: ignore[arg-type]
            self.context.append('</a>')
        elif 'filename' in node:
            atts['class'] += ' internal'
            atts['href'] = posixpath.join(
                self.builder.dlpath, urllib.parse.quote(node['filename'])
            )
            # TODO: TYPING: Upstream docutils should type HTMLTranslator.starttag()
            #       to accept **attributes; current stub treats the 4th parameter
            #       as empty: bool.
            self.body.append(self.starttag(node, 'a', '', **atts))  # type: ignore[arg-type]
            self.context.append('</a>')
        else:
            self.context.append('')

    def depart_download_reference(self, node: Element) -> None:
        self.body.append(self.context.pop())

    # overwritten
    def visit_figure(self, node: nodes.figure) -> None:
        # set align=default if align not specified to give a default style
        node.setdefault('align', 'default')

        return super().visit_figure(node)

    # overwritten
    def visit_image(self, node: nodes.image) -> None:
        olduri = node['uri']
        # rewrite the URI if the environment knows about it
        if olduri in self.builder.images:
            node['uri'] = posixpath.join(
                self.builder.imgpath, urllib.parse.quote(self.builder.images[olduri])
            )

        if 'scale' in node:
            # Try to figure out image height and width.  Docutils does that too,
            # but it tries the final file name, which does not necessarily exist
            # yet at the time the HTML file is written.
            if not ('width' in node and 'height' in node):
                size = get_image_size(self.builder.srcdir / olduri)
                if size is None:
                    logger.warning(
                        __('Could not obtain image size. :scale: option is ignored.'),
                        location=node,
                    )
                else:
                    if 'width' not in node:
                        node['width'] = str(size[0])
                    if 'height' not in node:
                        node['height'] = str(size[1])

        super().visit_image(node)

    # overwritten
    def depart_image(self, node: nodes.image) -> None:
        if node['uri'].lower().endswith(('svg', 'svgz')):
            pass
        else:
            super().depart_image(node)

    def visit_toctree(self, node: Element) -> None:
        # this only happens when formatting a toc from env.tocs -- in this
        # case we don't want to include the subtree
        raise nodes.SkipNode

    def visit_index(self, node: Element) -> None:
        raise nodes.SkipNode

    def visit_tabular_col_spec(self, node: Element) -> None:
        raise nodes.SkipNode

    def visit_glossary(self, node: Element) -> None:
        pass

    def depart_glossary(self, node: Element) -> None:
        pass

    def visit_acks(self, node: Element) -> None:
        pass

    def depart_acks(self, node: Element) -> None:
        pass

    def visit_hlist(self, node: Element) -> None:
        self.body.append('<table class="hlist"><tr>')

    def depart_hlist(self, node: Element) -> None:
        self.body.append('</tr></table>\n')

    def visit_hlistcol(self, node: Element) -> None:
        self.body.append('<td>')

    def depart_hlistcol(self, node: Element) -> None:
        self.body.append('</td>')

    # overwritten
    def visit_Text(self, node: Text) -> None:
        text = node.astext()
        encoded = self.encode(text)
        if self.protect_literal_text:
            # moved here from base class's visit_literal to support
            # more formatting in literal nodes
            for token in self.words_and_spaces.findall(encoded):
                if token.strip():
                    # protect literal text from line wrapping
                    self.body.append('<span class="pre">%s</span>' % token)
                elif token in {' ', '\n'}:
                    # allow breaks at whitespace
                    self.body.append(token)
                else:
                    # protect runs of multiple spaces; the last one can wrap
                    self.body.append('&#160;' * (len(token) - 1) + ' ')
        else:
            if self.in_mailto and self.settings.cloak_email_addresses:
                encoded = self.cloak_email(encoded)
            self.body.append(encoded)

    def visit_note(self, node: Element) -> None:
        self.visit_admonition(node, 'note')

    def depart_note(self, node: Element) -> None:
        self.depart_admonition(node)

    def visit_warning(self, node: Element) -> None:
        self.visit_admonition(node, 'warning')

    def depart_warning(self, node: Element) -> None:
        self.depart_admonition(node)

    def visit_attention(self, node: Element) -> None:
        self.visit_admonition(node, 'attention')

    def depart_attention(self, node: Element) -> None:
        self.depart_admonition(node)

    def visit_caution(self, node: Element) -> None:
        self.visit_admonition(node, 'caution')

    def depart_caution(self, node: Element) -> None:
        self.depart_admonition(node)

    def visit_danger(self, node: Element) -> None:
        self.visit_admonition(node, 'danger')

    def depart_danger(self, node: Element) -> None:
        self.depart_admonition(node)

    def visit_error(self, node: Element) -> None:
        self.visit_admonition(node, 'error')

    def depart_error(self, node: Element) -> None:
        self.depart_admonition(node)

    def visit_hint(self, node: Element) -> None:
        self.visit_admonition(node, 'hint')

    def depart_hint(self, node: Element) -> None:
        self.depart_admonition(node)

    def visit_important(self, node: Element) -> None:
        self.visit_admonition(node, 'important')

    def depart_important(self, node: Element) -> None:
        self.depart_admonition(node)

    def visit_tip(self, node: Element) -> None:
        self.visit_admonition(node, 'tip')

    def depart_tip(self, node: Element) -> None:
        self.depart_admonition(node)

    def visit_literal_emphasis(self, node: nodes.emphasis) -> None:
        return self.visit_emphasis(node)

    def depart_literal_emphasis(self, node: nodes.emphasis) -> None:
        return self.depart_emphasis(node)

    def visit_literal_strong(self, node: nodes.strong) -> None:
        return self.visit_strong(node)

    def depart_literal_strong(self, node: nodes.strong) -> None:
        return self.depart_strong(node)

    def visit_abbreviation(self, node: Element) -> None:
        attrs = {}
        if explanation := node.get('explanation', ''):
            attrs['title'] = explanation
        # TODO: TYPING: Upstream docutils should type HTMLTranslator.starttag()
        #       to accept **attributes; current stub treats the 4th parameter
        #       as empty: bool.
        self.body.append(self.starttag(node, 'abbr', '', **attrs))  # type: ignore[arg-type]

    def depart_abbreviation(self, node: Element) -> None:
        self.body.append('</abbr>')

    def visit_manpage(self, node: nodes.emphasis) -> None:
        self.visit_literal_emphasis(node)

    def depart_manpage(self, node: nodes.emphasis) -> None:
        self.depart_literal_emphasis(node)

    # overwritten to add even/odd classes

    def visit_table(self, node: nodes.table) -> None:
        self._table_row_indices.append(0)

        atts = {}
        classes = [cls.strip(' \t\n') for cls in self.settings.table_style.split(',')]
        classes.insert(0, 'docutils')  # compat

        # set align-default if align not specified to give a default style
        classes.append('align-%s' % node.get('align', 'default'))

        if 'width' in node:
            atts['style'] = 'width: %s' % node['width']
        # TODO: TYPING: Upstream docutils should type HTMLTranslator.starttag()
        #       to accept **attributes; current stub treats the 4th parameter
        #       as empty: bool.
        tag = self.starttag(node, 'table', CLASS=' '.join(classes), **atts)  # type: ignore[arg-type]
        self.body.append(tag)

    def depart_table(self, node: nodes.table) -> None:
        self._table_row_indices.pop()
        super().depart_table(node)

    def visit_row(self, node: Element) -> None:
        self._table_row_indices[-1] += 1
        if self._table_row_indices[-1] % 2 == 0:
            node['classes'].append('row-even')
        else:
            node['classes'].append('row-odd')
        self.body.append(self.starttag(node, 'tr', ''))
        node.column = 0  # type: ignore[attr-defined]

    def visit_field_list(self, node: nodes.field_list) -> None:
        self._fieldlist_row_indices.append(0)
        return super().visit_field_list(node)

    def depart_field_list(self, node: nodes.field_list) -> None:
        self._fieldlist_row_indices.pop()
        return super().depart_field_list(node)

    def visit_field(self, node: Element) -> None:
        self._fieldlist_row_indices[-1] += 1
        if self._fieldlist_row_indices[-1] % 2 == 0:
            node['classes'].append('field-even')
        else:
            node['classes'].append('field-odd')

    def visit_math(self, node: nodes.math, math_env: str = '') -> None:
        self._has_maths_elements = True

        # see validate_math_renderer
        name: str = self.builder.math_renderer_name  # type: ignore[assignment]
        visit, _ = self.builder.env._registry.html_inline_math_renderers[name]
        visit(self, node)

    def depart_math(self, node: nodes.math, math_env: str = '') -> None:
        # see validate_math_renderer
        name: str = self.builder.math_renderer_name  # type: ignore[assignment]
        _, depart = self.builder.env._registry.html_inline_math_renderers[name]
        if depart:
            depart(self, node)

    def visit_math_block(self, node: nodes.math_block, math_env: str = '') -> None:
        self._has_maths_elements = True

        # see validate_math_renderer
        name: str = self.builder.math_renderer_name  # type: ignore[assignment]
        visit, _ = self.builder.env._registry.html_block_math_renderers[name]
        visit(self, node)

    def depart_math_block(self, node: nodes.math_block, math_env: str = '') -> None:
        # see validate_math_renderer
        name: str = self.builder.math_renderer_name  # type: ignore[assignment]
        _, depart = self.builder.env._registry.html_block_math_renderers[name]
        if depart:
            depart(self, node)

    # See Docutils r9413
    # Re-instate the footnote-reference class
    def visit_footnote_reference(self, node: Element) -> None:
        href = '#' + node['refid']
        classes = ['footnote-reference', self.settings.footnote_references]
        self.body.append(
            self.starttag(
                node, 'a', suffix='', classes=classes, role='doc-noteref', href=href
            )
        )
        self.body.append('<span class="fn-bracket">[</span>')
