"""Custom docutils writer for Texinfo."""

from __future__ import annotations

import os.path
import re
import textwrap
from typing import TYPE_CHECKING, cast

from docutils import nodes, writers

from sphinx import __display_version__, addnodes
from sphinx.locale import _, __, admonitionlabels
from sphinx.util import logging
from sphinx.util.docutils import SphinxTranslator
from sphinx.util.i18n import format_date
from sphinx.writers.latex import collected_footnote

if TYPE_CHECKING:
    from collections.abc import Iterable, Iterator
    from typing import Any, ClassVar

    from docutils.nodes import Element, Node, Text

    from sphinx.builders.texinfo import TexinfoBuilder
    from sphinx.domains import IndexEntry


logger = logging.getLogger(__name__)


COPYING = """\
@quotation
%(project)s %(release)s, %(date)s

%(author)s

Copyright @copyright{} %(copyright)s
@end quotation
"""

TEMPLATE = f"""\
\\input texinfo   @c -*-texinfo-*-
@c %%**start of header
@setfilename %(filename)s
@documentencoding UTF-8
@ifinfo
@*Generated by Sphinx {__display_version__}.@*
@end ifinfo
@settitle %(title)s
@defindex ge
@paragraphindent %(paragraphindent)s
@exampleindent %(exampleindent)s
@finalout
%(direntry)s
@c %%**end of header

@copying
%(copying)s
@end copying

@titlepage
@title %(title)s
@insertcopying
@end titlepage
@contents

@c %%** start of user preamble
%(preamble)s
@c %%** end of user preamble

@ifnottex
@node Top
@top %(title)s
@insertcopying
@end ifnottex

@c %%**start of body
%(body)s
@c %%**end of body
@bye
"""


def find_subsections(section: Element) -> list[nodes.section]:
    """Return a list of subsections for the given ``section``."""
    result = []
    for child in section:
        if isinstance(child, nodes.section):
            result.append(child)
            continue
        if isinstance(child, nodes.Element):
            result.extend(find_subsections(child))
    return result


def smart_capwords(s: str, sep: str | None = None) -> str:
    """Like string.capwords() but does not capitalize words that already
    contain a capital letter.
    """
    words = s.split(sep)
    for i, word in enumerate(words):
        if all(x.islower() for x in word):
            words[i] = word.capitalize()
    return (sep or ' ').join(words)


class TexinfoWriter(writers.Writer):  # type: ignore[type-arg]
    """Texinfo writer for generating Texinfo documents."""

    supported = ('texinfo', 'texi')

    settings_spec = (
        'Texinfo Specific Options',
        None,
        (
            ('Name of the Info file', ['--texinfo-filename'], {'default': ''}),
            ('Dir entry', ['--texinfo-dir-entry'], {'default': ''}),
            ('Description', ['--texinfo-dir-description'], {'default': ''}),
            ('Category', ['--texinfo-dir-category'], {'default': 'Miscellaneous'}),
        ),
    )

    settings_defaults: ClassVar[dict[str, Any]] = {}

    output: str

    visitor_attributes = ('output', 'fragment')

    def __init__(self, builder: TexinfoBuilder) -> None:
        super().__init__()
        self.builder = builder

    def translate(self) -> None:
        assert isinstance(self.document, nodes.document)
        visitor = self.builder.create_translator(self.document, self.builder)
        self.visitor = cast('TexinfoTranslator', visitor)
        self.document.walkabout(visitor)
        self.visitor.finish()
        for attr in self.visitor_attributes:
            setattr(self, attr, getattr(self.visitor, attr))


class TexinfoTranslator(SphinxTranslator):
    ignore_missing_images = False
    builder: TexinfoBuilder

    default_elements = {
        'author': '',
        'body': '',
        'copying': '',
        'date': '',
        'direntry': '',
        'exampleindent': 4,
        'filename': '',
        'paragraphindent': 0,
        'preamble': '',
        'project': '',
        'release': '',
        'title': '',
    }

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

        self.written_ids: set[str] = set()  # node names and anchors in output
        # node names and anchors that should be in output
        self.referenced_ids: set[str] = set()
        self.indices: list[tuple[str, str]] = []  # (node name, content)
        self.short_ids: dict[str, str] = {}  # anchors --> short ids
        self.node_names: dict[str, str] = {}  # node name --> node's name to display
        self.node_menus: dict[str, list[str]] = {}  # node name --> node's menu entries
        self.rellinks: dict[str, list[str]] = {}  # node name --> (next, previous, up)

        self.collect_indices()
        self.collect_node_names()
        self.collect_node_menus()
        self.collect_rellinks()

        self.body: list[str] = []
        self.context: list[str] = []
        self.descs: list[addnodes.desc] = []
        self.previous_section: nodes.section | None = None
        self.section_level = 0
        self.seen_title = False
        self.next_section_ids: set[str] = set()
        self.escape_newlines = 0
        self.escape_hyphens = 0
        self.curfilestack: list[str] = []
        self.footnotestack: list[dict[str, list[collected_footnote | bool]]] = []
        self.in_production_list = False
        self.in_footnote = 0
        self.in_samp = 0
        self.handled_abbrs: set[str] = set()
        self.colwidths: list[int] = []

    def finish(self) -> None:
        if self.previous_section is None:
            self.add_menu('Top')
        for index in self.indices:
            name, content = index
            pointers = tuple([name] + self.rellinks[name])
            self.body.append('\n@node %s,%s,%s,%s\n' % pointers)
            self.body.append(f'@unnumbered {name}\n\n{content}\n')

        while self.referenced_ids:
            # handle xrefs with missing anchors
            r = self.referenced_ids.pop()
            if r not in self.written_ids:
                self.body.append('@anchor{{{}}}@w{{{}}}\n'.format(r, ' ' * 30))
        self.ensure_eol()
        self.fragment = ''.join(self.body)
        self.elements['body'] = self.fragment
        self.output = TEMPLATE % self.elements

    # -- Helper routines

    def init_settings(self) -> None:
        today_fmt = self.config.today_fmt or _('%b %d, %Y')
        today = self.config.today or format_date(
            today_fmt, language=self.config.language
        )
        elements = self.elements = self.default_elements.copy()
        elements.update({
            # if empty, the title is set to the first section title
            'title': self.settings.title,
            'author': self.settings.author,
            # if empty, use basename of input file
            'filename': self.settings.texinfo_filename,
            'release': self.escape(self.config.release),
            'project': self.escape(self.config.project),
            'copyright': self.escape(self.config.copyright),
            'date': self.escape(today),
        })
        # title
        title: str = self.settings.title
        if not title:
            title_node = self.document.next_node(nodes.title)
            title = title_node.astext() if title_node else '<untitled>'
        elements['title'] = self.escape_id(title) or '<untitled>'
        # filename
        if not elements['filename']:
            elements['filename'] = self.document.get('source') or 'untitled'
            if elements['filename'][-4:] in {'.txt', '.rst'}:  # type: ignore[index]
                elements['filename'] = elements['filename'][:-4]  # type: ignore[index]
            elements['filename'] += '.info'  # type: ignore[operator]
        # direntry
        if self.settings.texinfo_dir_entry:
            entry = self.format_menu_entry(
                self.escape_menu(self.settings.texinfo_dir_entry),
                '(%s)' % elements['filename'],
                self.escape_arg(self.settings.texinfo_dir_description),
            )
            elements['direntry'] = '@dircategory %s\n@direntry\n%s@end direntry\n' % (
                self.escape_id(self.settings.texinfo_dir_category),
                entry,
            )
        elements['copying'] = COPYING % elements
        # allow the user to override them all
        elements.update(self.settings.texinfo_elements)

    def collect_node_names(self) -> None:
        """Generates a unique id for each section.

        Assigns the attribute ``node_name`` to each section.
        """

        def add_node_name(name: str) -> str:
            node_id = self.escape_id(name)
            nth, suffix = 1, ''
            while (
                node_id + suffix in self.written_ids
                or node_id + suffix in self.node_names
            ):
                nth += 1
                suffix = '<%s>' % nth
            node_id += suffix
            self.written_ids.add(node_id)
            self.node_names[node_id] = name
            return node_id

        # must have a "Top" node
        self.document['node_name'] = 'Top'
        add_node_name('Top')
        add_node_name('top')
        # each index is a node
        self.indices = [
            (add_node_name(name), content) for name, content in self.indices
        ]
        # each section is also a node
        for section in self.document.findall(nodes.section):
            # TODO: TYPING: Node.next_node() should accept the non-Node
            #       mixin classes in docutils.nodes.
            title = section.next_node(nodes.Titular)  # type: ignore[type-var]
            assert isinstance(title, nodes.Node)
            name = title.astext() if title else '<untitled>'
            section['node_name'] = add_node_name(name)

    def collect_node_menus(self) -> None:
        """Collect the menu entries for each "node" section."""
        node_menus = self.node_menus
        targets: list[Element] = [self.document]
        targets.extend(self.document.findall(nodes.section))
        for node in targets:
            assert node.get('node_name', False)
            entries = [s['node_name'] for s in find_subsections(node)]
            node_menus[node['node_name']] = entries
        # try to find a suitable "Top" node
        title = self.document.next_node(nodes.title)
        top = title.parent if title else self.document
        if not isinstance(top, (nodes.document, nodes.section)):
            top = self.document
        if top is not self.document:
            entries = node_menus[top['node_name']]
            entries += node_menus['Top'][1:]
            node_menus['Top'] = entries
            del node_menus[top['node_name']]
            top['node_name'] = 'Top'
        # handle the indices
        for name, _content in self.indices:
            node_menus[name] = []
            node_menus['Top'].append(name)

    def collect_rellinks(self) -> None:
        """Collect the relative links (next, previous, up) for each "node"."""
        rellinks = self.rellinks
        node_menus = self.node_menus
        for id in node_menus:
            rellinks[id] = ['', '', '']
        # up's
        for id, entries in node_menus.items():
            for e in entries:
                rellinks[e][2] = id
        # next's and prev's
        for id, entries in node_menus.items():
            for i, id in enumerate(entries):
                # First child's prev is empty
                if i != 0:
                    rellinks[id][1] = entries[i - 1]
                # Last child's next is empty
                if i != len(entries) - 1:
                    rellinks[id][0] = entries[i + 1]
        # top's next is its first child
        try:
            first = node_menus['Top'][0]
        except IndexError:
            pass
        else:
            rellinks['Top'][0] = first
            rellinks[first][1] = 'Top'

    # -- Escaping
    # Which characters to escape depends on the context.  In some cases,
    # namely menus and node names, it's not possible to escape certain
    # characters.

    def escape(self, s: str) -> str:
        """Return a string with Texinfo command characters escaped."""
        s = s.replace('@', '@@')
        s = s.replace('{', '@{')
        s = s.replace('}', '@}')
        # prevent `` and '' quote conversion
        s = s.replace('``', '`@w{`}')
        s = s.replace("''", "'@w{'}")
        return s

    def escape_arg(self, s: str) -> str:
        """Return an escaped string suitable for use as an argument
        to a Texinfo command.
        """
        s = self.escape(s)
        # commas are the argument delimiters
        s = s.replace(',', '@comma{}')
        # normalize white space
        s = ' '.join(s.split()).strip()
        return s

    def escape_id(self, s: str) -> str:
        """Return an escaped string suitable for node names and anchors."""
        bad_chars = ',:()'
        for bc in bad_chars:
            s = s.replace(bc, ' ')
        if re.search('[^ .]', s):
            # remove DOTs if name contains other characters
            s = s.replace('.', ' ')
        s = ' '.join(s.split()).strip()
        return self.escape(s)

    def escape_menu(self, s: str) -> str:
        """Return an escaped string suitable for menu entries."""
        s = self.escape_arg(s)
        s = s.replace(':', ';')
        s = ' '.join(s.split()).strip()
        return s

    def ensure_eol(self) -> None:
        """Ensure the last line in body is terminated by new line."""
        if self.body and self.body[-1][-1:] != '\n':
            self.body.append('\n')

    def format_menu_entry(self, name: str, node_name: str, desc: str) -> str:
        if name == node_name:
            s = f'* {name}:: '
        else:
            s = f'* {name}: {node_name}. '
        offset = max((24, (len(name) + 4) % 78))
        wdesc = '\n'.join(
            ' ' * offset + l for l in textwrap.wrap(desc, width=78 - offset)
        )
        return s + wdesc.strip() + '\n'

    def add_menu_entries(
        self,
        entries: list[str],
        reg: re.Pattern[str] = re.compile(r'\s+---?\s+'),
    ) -> None:
        for entry in entries:
            name = self.node_names[entry]
            # special formatting for entries that are divided by an em-dash
            try:
                parts = reg.split(name, maxsplit=1)
            except TypeError:
                # could be a gettext proxy
                parts = [name]
            if len(parts) == 2:
                name, desc = parts
            else:
                desc = ''
            name = self.escape_menu(name)
            desc = self.escape(desc)
            self.body.append(self.format_menu_entry(name, entry, desc))

    def add_menu(self, node_name: str) -> None:
        entries = self.node_menus[node_name]
        if not entries:
            return
        self.body.append('\n@menu\n')
        self.add_menu_entries(entries)
        if (
            node_name != 'Top'
            or not self.node_menus[entries[0]]
            or self.config.texinfo_no_detailmenu
        ):
            self.body.append('\n@end menu\n')
            return

        def _add_detailed_menu(name: str) -> None:
            entries = self.node_menus[name]
            if not entries:
                return
            self.body.append(f'\n{self.escape(self.node_names[name])}\n\n')
            self.add_menu_entries(entries)
            for subentry in entries:
                _add_detailed_menu(subentry)

        self.body.append('\n@detailmenu\n --- The Detailed Node Listing ---\n')
        for entry in entries:
            _add_detailed_menu(entry)
        self.body.append('\n@end detailmenu\n@end menu\n')

    def tex_image_length(self, width_str: str) -> str:
        match = re.match(r'(\d*\.?\d*)\s*(\S*)', width_str)
        if not match:
            # fallback
            return width_str
        res = width_str
        amount, unit = match.groups()[:2]
        if not unit or unit == 'px':
            # pixels: let TeX alone
            return ''
        elif unit == '%':
            # a4paper: textwidth=418.25368pt
            res = '%d.0pt' % (float(amount) * 4.1825368)
        return res

    def collect_indices(self) -> None:
        def generate(
            content: list[tuple[str, list[IndexEntry]]], collapsed: bool
        ) -> str:
            ret = ['\n@menu\n']
            for _letter, entries in content:
                for entry in entries:
                    if not entry[3]:
                        continue
                    name = self.escape_menu(entry[0])
                    sid = self.get_short_id(f'{entry[2]}:{entry[3]}')
                    desc = self.escape_arg(entry[6])
                    me = self.format_menu_entry(name, sid, desc)
                    ret.append(me)
            ret.append('@end menu\n')
            return ''.join(ret)

        if indices_config := self.config.texinfo_domain_indices:
            if not isinstance(indices_config, bool):
                check_names = True
                indices_config = frozenset(indices_config)
            else:
                check_names = False
            for domain in self._domains.sorted():
                for index_cls in domain.indices:
                    index_name = f'{domain.name}-{index_cls.name}'
                    if check_names and index_name not in indices_config:
                        continue
                    content, collapsed = index_cls(domain).generate(
                        self.builder.docnames
                    )
                    if content:
                        self.indices.append((
                            index_cls.localname,
                            generate(content, collapsed),
                        ))
        # only add the main Index if it's not empty
        domain = self._domains.index_domain
        for docname in self.builder.docnames:
            if domain.entries[docname]:
                self.indices.append((_('Index'), '\n@printindex ge\n'))
                break

    # this is copied from the latex writer
    # TODO: move this to sphinx.util

    def collect_footnotes(
        self, node: Element
    ) -> dict[str, list[collected_footnote | bool]]:
        def footnotes_under(n: Element) -> Iterator[nodes.footnote]:
            if isinstance(n, nodes.footnote):
                yield n
            else:
                for c in n.children:
                    if isinstance(c, addnodes.start_of_file):
                        continue
                    elif isinstance(c, nodes.Element):
                        yield from footnotes_under(c)

        fnotes: dict[str, list[collected_footnote | bool]] = {}
        for fn in footnotes_under(node):
            label = cast('nodes.label', fn[0])
            num = label.astext().strip()
            fnotes[num] = [collected_footnote('', *fn.children), False]
        return fnotes

    # -- xref handling

    def get_short_id(self, id: str) -> str:
        """Return a shorter 'id' associated with ``id``."""
        # Shorter ids improve paragraph filling in places
        # that the id is hidden by Emacs.
        try:
            sid = self.short_ids[id]
        except KeyError:
            sid = f'{len(self.short_ids):x}'
            self.short_ids[id] = sid
        return sid

    def add_anchor(self, id: str, node: Node) -> None:
        if id.startswith('index-'):
            return
        id = self.curfilestack[-1] + ':' + id
        eid = self.escape_id(id)
        sid = self.get_short_id(id)
        for id in (eid, sid):
            if id not in self.written_ids:
                self.body.append('@anchor{%s}' % id)
                self.written_ids.add(id)

    def add_xref(self, id: str, name: str, node: Node) -> None:
        name = self.escape_menu(name)
        sid = self.get_short_id(id)
        if self.config.texinfo_cross_references:
            self.body.append(f'@ref{{{sid},,{name}}}')
            self.referenced_ids.add(sid)
            self.referenced_ids.add(self.escape_id(id))
        else:
            self.body.append(name)

    # -- Visiting

    def visit_document(self, node: Element) -> None:
        self.footnotestack.append(self.collect_footnotes(node))
        self.curfilestack.append(node.get('docname', ''))
        if 'docname' in node:
            self.add_anchor(':doc', node)

    def depart_document(self, node: Element) -> None:
        self.footnotestack.pop()
        self.curfilestack.pop()

    def visit_Text(self, node: Text) -> None:
        s = self.escape(node.astext())
        if self.escape_newlines:
            s = s.replace('\n', ' ')
        if self.escape_hyphens:
            # prevent "--" and "---" conversion
            s = s.replace('-', '@w{-}')
        self.body.append(s)

    def depart_Text(self, node: Text) -> None:
        pass

    def visit_section(self, node: Element) -> None:
        self.next_section_ids.update(node.get('ids', []))
        if not self.seen_title:
            return
        if self.previous_section:
            self.add_menu(self.previous_section['node_name'])
        else:
            self.add_menu('Top')

        node_name = node['node_name']
        pointers = tuple([node_name] + self.rellinks[node_name])
        self.body.append('\n@node %s,%s,%s,%s\n' % pointers)
        for id in sorted(self.next_section_ids):
            self.add_anchor(id, node)

        self.next_section_ids.clear()
        self.previous_section = cast('nodes.section', node)
        self.section_level += 1

    def depart_section(self, node: Element) -> None:
        self.section_level -= 1

    headings = (
        '@unnumbered',
        '@chapter',
        '@section',
        '@subsection',
        '@subsubsection',
    )

    rubrics = (
        '@heading',
        '@subheading',
        '@subsubheading',
    )

    def visit_title(self, node: Element) -> None:
        if not self.seen_title:
            self.seen_title = True
            raise nodes.SkipNode
        parent = node.parent
        if isinstance(parent, nodes.table):
            return
        if isinstance(parent, (nodes.Admonition, nodes.sidebar, nodes.topic)):
            raise nodes.SkipNode
        if not isinstance(parent, nodes.section):
            logger.warning(
                __(
                    'encountered title node not in section, topic, table, '
                    'admonition or sidebar'
                ),
                location=node,
            )
            self.visit_rubric(node)
        else:
            try:
                heading = self.headings[self.section_level]
            except IndexError:
                heading = self.headings[-1]
            self.body.append('\n%s ' % heading)

    def depart_title(self, node: Element) -> None:
        self.body.append('\n\n')

    def visit_rubric(self, node: Element) -> None:
        if len(node) == 1 and node.astext() in {'Footnotes', _('Footnotes')}:
            raise nodes.SkipNode
        try:
            rubric = self.rubrics[self.section_level]
        except IndexError:
            rubric = self.rubrics[-1]
        self.body.append('\n%s ' % rubric)
        self.escape_newlines += 1

    def depart_rubric(self, node: Element) -> None:
        self.escape_newlines -= 1
        self.body.append('\n\n')

    def visit_subtitle(self, node: Element) -> None:
        self.body.append('\n\n@noindent\n')

    def depart_subtitle(self, node: Element) -> None:
        self.body.append('\n\n')

    # -- References

    def visit_target(self, node: Element) -> None:
        # postpone the labels until after the sectioning command
        parindex = node.parent.index(node)
        try:
            try:
                next = node.parent[parindex + 1]
            except IndexError:
                # last node in parent, look at next after parent
                # (for section of equal level)
                next = node.parent.parent[node.parent.parent.index(node.parent)]
            if isinstance(next, nodes.section):
                if node.get('refid'):
                    self.next_section_ids.add(node['refid'])
                self.next_section_ids.update(node['ids'])
                return
        except (IndexError, AttributeError):
            pass
        if 'refuri' in node:
            return
        if node.get('refid'):
            self.add_anchor(node['refid'], node)
        for id in node['ids']:
            self.add_anchor(id, node)

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

    def visit_reference(self, node: Element) -> None:
        # an xref's target is displayed in Info so we ignore a few
        # cases for the sake of appearance
        if isinstance(node.parent, (nodes.title, addnodes.desc_type)):
            return
        if len(node) != 0 and isinstance(node[0], nodes.image):
            return
        name = node.get('name', node.astext()).strip()
        uri = node.get('refuri', '')
        if not uri and node.get('refid'):
            uri = '%' + self.curfilestack[-1] + '#' + node['refid']
        if not uri:
            return
        if uri.startswith('mailto:'):
            uri = self.escape_arg(uri[7:])
            name = self.escape_arg(name)
            if not name or name == uri:
                self.body.append('@email{%s}' % uri)
            else:
                self.body.append(f'@email{{{uri},{name}}}')
        elif uri.startswith('#'):
            # references to labels in the same document
            id = self.curfilestack[-1] + ':' + uri[1:]
            self.add_xref(id, name, node)
        elif uri.startswith('%'):
            # references to documents or labels inside documents
            hashindex = uri.find('#')
            if hashindex == -1:
                # reference to the document
                id = uri[1:] + '::doc'
            else:
                # reference to a label
                id = uri[1:].replace('#', ':')
            self.add_xref(id, name, node)
        elif uri.startswith('info:'):
            # references to an external Info file
            uri = uri[5:].replace('_', ' ')
            uri = self.escape_arg(uri)
            id = 'Top'
            if '#' in uri:
                uri, _, id = uri.partition('#')
            id = self.escape_id(id)
            name = self.escape_menu(name)
            if name == id:
                self.body.append(f'@ref{{{id},,,{uri}}}')
            else:
                self.body.append(f'@ref{{{id},,{name},{uri}}}')
        else:
            uri = self.escape_arg(uri)
            name = self.escape_arg(name)
            show_urls = self.config.texinfo_show_urls
            if self.in_footnote:
                show_urls = 'inline'
            if not name or uri == name:
                self.body.append('@indicateurl{%s}' % uri)
            elif show_urls == 'inline':
                self.body.append(f'@uref{{{uri},{name}}}')
            elif show_urls == 'no':
                self.body.append(f'@uref{{{uri},,{name}}}')
            else:
                self.body.append(f'{name}@footnote{{{uri}}}')
        raise nodes.SkipNode

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

    def visit_number_reference(self, node: Element) -> None:
        text = nodes.Text(node.get('title', '#'))
        self.visit_Text(text)
        raise nodes.SkipNode

    def visit_title_reference(self, node: Element) -> None:
        text = node.astext()
        self.body.append('@cite{%s}' % self.escape_arg(text))
        raise nodes.SkipNode

    # -- Blocks

    def visit_paragraph(self, node: Element) -> None:
        self.body.append('\n')

    def depart_paragraph(self, node: Element) -> None:
        self.body.append('\n')

    def visit_block_quote(self, node: Element) -> None:
        self.body.append('\n@quotation\n')

    def depart_block_quote(self, node: Element) -> None:
        self.ensure_eol()
        self.body.append('@end quotation\n')

    def visit_literal_block(self, node: Element | None) -> None:
        self.body.append('\n@example\n')

    def depart_literal_block(self, node: Element | None) -> None:
        self.ensure_eol()
        self.body.append('@end example\n')

    visit_doctest_block = visit_literal_block
    depart_doctest_block = depart_literal_block

    def visit_line_block(self, node: Element) -> None:
        if not isinstance(node.parent, nodes.line_block):
            self.body.append('\n\n')
        self.body.append('@display\n')

    def depart_line_block(self, node: Element) -> None:
        self.body.append('@end display\n')
        if not isinstance(node.parent, nodes.line_block):
            self.body.append('\n\n')

    def visit_line(self, node: Element) -> None:
        self.escape_newlines += 1

    def depart_line(self, node: Element) -> None:
        self.body.append('@w{ }\n')
        self.escape_newlines -= 1

    # -- Inline

    def visit_strong(self, node: Element) -> None:
        self.body.append('`')

    def depart_strong(self, node: Element) -> None:
        self.body.append("'")

    def visit_emphasis(self, node: Element) -> None:
        if self.in_samp:
            self.body.append('@var{')
            self.context.append('}')
        else:
            self.body.append('`')
            self.context.append("'")

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

    def is_samp(self, node: Element) -> bool:
        return 'samp' in node['classes']

    def visit_literal(self, node: Element) -> None:
        if self.is_samp(node):
            self.in_samp += 1
        self.body.append('@code{')

    def depart_literal(self, node: Element) -> None:
        if self.is_samp(node):
            self.in_samp -= 1
        self.body.append('}')

    def visit_superscript(self, node: Element) -> None:
        self.body.append('@w{^')

    def depart_superscript(self, node: Element) -> None:
        self.body.append('}')

    def visit_subscript(self, node: Element) -> None:
        self.body.append('@w{[')

    def depart_subscript(self, node: Element) -> None:
        self.body.append(']}')

    # -- Footnotes

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

    def visit_collected_footnote(self, node: Element) -> None:
        self.in_footnote += 1
        self.body.append('@footnote{')

    def depart_collected_footnote(self, node: Element) -> None:
        self.body.append('}')
        self.in_footnote -= 1

    def visit_footnote_reference(self, node: Element) -> None:
        num = node.astext().strip()
        try:
            footnode, _used = self.footnotestack[-1][num]
        except (KeyError, IndexError) as exc:
            raise nodes.SkipNode from exc
        # footnotes are repeated for each reference
        footnode.walkabout(self)  # type: ignore[union-attr]
        raise nodes.SkipChildren

    def visit_citation(self, node: Element) -> None:
        self.body.append('\n')
        for id in node.get('ids'):
            self.add_anchor(id, node)
        self.escape_newlines += 1

    def depart_citation(self, node: Element) -> None:
        self.escape_newlines -= 1

    def visit_citation_reference(self, node: Element) -> None:
        self.body.append('@w{[')

    def depart_citation_reference(self, node: Element) -> None:
        self.body.append(']}')

    # -- Lists

    def visit_bullet_list(self, node: Element) -> None:
        bullet = node.get('bullet', '*')
        self.body.append('\n\n@itemize %s\n' % bullet)

    def depart_bullet_list(self, node: Element) -> None:
        self.ensure_eol()
        self.body.append('@end itemize\n')

    def visit_enumerated_list(self, node: Element) -> None:
        # doesn't support Roman numerals
        enum = node.get('enumtype', 'arabic')
        starters = {'arabic': '', 'loweralpha': 'a', 'upperalpha': 'A'}
        start = node.get('start', starters.get(enum, ''))
        self.body.append('\n\n@enumerate %s\n' % start)

    def depart_enumerated_list(self, node: Element) -> None:
        self.ensure_eol()
        self.body.append('@end enumerate\n')

    def visit_list_item(self, node: Element) -> None:
        self.body.append('\n@item ')

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

    # -- Option List

    def visit_option_list(self, node: Element) -> None:
        self.body.append('\n\n@table @option\n')

    def depart_option_list(self, node: Element) -> None:
        self.ensure_eol()
        self.body.append('@end table\n')

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

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

    def visit_option_group(self, node: Element) -> None:
        self.at_item_x = '@item'

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

    def visit_option(self, node: Element) -> None:
        self.escape_hyphens += 1
        self.body.append('\n%s ' % self.at_item_x)
        self.at_item_x = '@itemx'

    def depart_option(self, node: Element) -> None:
        self.escape_hyphens -= 1

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

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

    def visit_option_argument(self, node: Element) -> None:
        self.body.append(node.get('delimiter', ' '))

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

    def visit_description(self, node: Element) -> None:
        self.body.append('\n')

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

    # -- Definitions

    def visit_definition_list(self, node: Element) -> None:
        self.body.append('\n\n@table @asis\n')

    def depart_definition_list(self, node: Element) -> None:
        self.ensure_eol()
        self.body.append('@end table\n')

    def visit_definition_list_item(self, node: Element) -> None:
        self.at_item_x = '@item'

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

    def visit_term(self, node: Element) -> None:
        for id in node.get('ids'):
            self.add_anchor(id, node)
        # anchors and indexes need to go in front
        for n in node[::]:
            if isinstance(n, (addnodes.index, nodes.target)):
                n.walkabout(self)
                node.remove(n)
        self.body.append('\n%s ' % self.at_item_x)
        self.at_item_x = '@itemx'

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

    def visit_classifier(self, node: Element) -> None:
        self.body.append(' : ')

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

    def visit_definition(self, node: Element) -> None:
        self.body.append('\n')

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

    # -- Tables

    def visit_table(self, node: Element) -> None:
        self.entry_sep = '@item'

    def depart_table(self, node: Element) -> None:
        self.body.append('\n@end multitable\n\n')

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

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

    def visit_colspec(self, node: Element) -> None:
        self.colwidths.append(node['colwidth'])
        if len(self.colwidths) != self.n_cols:
            return
        self.body.append('\n\n@multitable ')
        for n in self.colwidths:
            self.body.append('{%s} ' % ('x' * (n + 2)))

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

    def visit_tgroup(self, node: Element) -> None:
        self.colwidths = []
        self.n_cols = node['cols']

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

    def visit_thead(self, node: Element) -> None:
        self.entry_sep = '@headitem'

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

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

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

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

    def depart_row(self, node: Element) -> None:
        self.entry_sep = '@item'

    def visit_entry(self, node: Element) -> None:
        self.body.append('\n%s\n' % self.entry_sep)
        self.entry_sep = '@tab'

    def depart_entry(self, node: Element) -> None:
        for _i in range(node.get('morecols', 0)):
            self.body.append('\n@tab\n')

    # -- Field Lists

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

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

    def visit_field(self, node: Element) -> None:
        self.body.append('\n')

    def depart_field(self, node: Element) -> None:
        self.body.append('\n')

    def visit_field_name(self, node: Element) -> None:
        self.ensure_eol()
        self.body.append('@*')

    def depart_field_name(self, node: Element) -> None:
        self.body.append(': ')

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

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

    # -- Admonitions

    def visit_admonition(self, node: Element, name: str = '') -> None:
        if not name:
            title = cast('nodes.title', node[0])
            name = self.escape(title.astext())
        self.body.append('\n@cartouche\n@quotation %s ' % name)

    def _visit_named_admonition(self, node: Element) -> None:
        label = admonitionlabels[node.tagname]
        self.body.append('\n@cartouche\n@quotation %s ' % label)

    def depart_admonition(self, node: Element) -> None:
        self.ensure_eol()
        self.body.append('@end quotation\n@end cartouche\n')

    visit_attention = _visit_named_admonition
    depart_attention = depart_admonition
    visit_caution = _visit_named_admonition
    depart_caution = depart_admonition
    visit_danger = _visit_named_admonition
    depart_danger = depart_admonition
    visit_error = _visit_named_admonition
    depart_error = depart_admonition
    visit_hint = _visit_named_admonition
    depart_hint = depart_admonition
    visit_important = _visit_named_admonition
    depart_important = depart_admonition
    visit_note = _visit_named_admonition
    depart_note = depart_admonition
    visit_tip = _visit_named_admonition
    depart_tip = depart_admonition
    visit_warning = _visit_named_admonition
    depart_warning = depart_admonition

    # -- Misc

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

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

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

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

    def visit_container(self, node: Element) -> None:
        if node.get('literal_block'):
            self.body.append('\n\n@float LiteralBlock\n')

    def depart_container(self, node: Element) -> None:
        if node.get('literal_block'):
            self.body.append('\n@end float\n\n')

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

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

    def visit_topic(self, node: Element) -> None:
        # ignore TOC's since we have to have a "menu" anyway
        if (classes := node.get('classes', ())) and 'contents' in classes:
            raise nodes.SkipNode
        title = cast('nodes.title', node[0])
        self.visit_rubric(title)
        self.body.append('%s\n' % self.escape(title.astext()))
        self.depart_rubric(title)

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

    def visit_transition(self, node: Element) -> None:
        self.body.append('\n\n%s\n\n' % ('_' * 66))

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

    def visit_attribution(self, node: Element) -> None:
        self.body.append('\n\n@center --- ')

    def depart_attribution(self, node: Element) -> None:
        self.body.append('\n\n')

    def visit_raw(self, node: Element) -> None:
        format = node.get('format', '').split()
        if 'texinfo' in format or 'texi' in format:
            self.body.append(node.astext())
        raise nodes.SkipNode

    def visit_figure(self, node: Element) -> None:
        self.body.append('\n\n@float Figure\n')

    def depart_figure(self, node: Element) -> None:
        self.body.append('\n@end float\n\n')

    def visit_caption(self, node: Element) -> None:
        if isinstance(node.parent, nodes.figure) or (
            isinstance(node.parent, nodes.container)
            and node.parent.get('literal_block')
        ):
            self.body.append('\n@caption{')
        else:
            logger.warning(__('caption not inside a figure.'), location=node)

    def depart_caption(self, node: Element) -> None:
        if isinstance(node.parent, nodes.figure) or (
            isinstance(node.parent, nodes.container)
            and node.parent.get('literal_block')
        ):
            self.body.append('}\n')

    def visit_image(self, node: Element) -> None:
        if node['uri'] in self.builder.images:
            uri = self.builder.images[node['uri']]
        else:
            # missing image!
            if self.ignore_missing_images:
                return
            uri = node['uri']
        if uri.find('://') != -1:
            # ignore remote images
            return
        name, ext = os.path.splitext(uri)
        # width and height ignored in non-tex output
        width = self.tex_image_length(node.get('width', ''))
        height = self.tex_image_length(node.get('height', ''))
        alt = self.escape_arg(node.get('alt', ''))
        filename = f'{self.elements["filename"][:-5]}-figures/{name}'  # type: ignore[index]
        self.body.append(f'\n@image{{{filename},{width},{height},{alt},{ext[1:]}}}\n')

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

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

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

    def visit_sidebar(self, node: Element) -> None:
        self.visit_topic(node)

    def depart_sidebar(self, node: Element) -> None:
        self.depart_topic(node)

    def visit_label(self, node: Element) -> None:
        # label numbering is automatically generated by Texinfo
        if self.in_footnote:
            raise nodes.SkipNode
        self.body.append('@w{(')

    def depart_label(self, node: Element) -> None:
        self.body.append(')} ')

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

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

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

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

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

    def visit_system_message(self, node: Element) -> None:
        self.body.append(
            '\n@verbatim\n<SYSTEM MESSAGE: %s>\n@end verbatim\n' % node.astext()
        )
        raise nodes.SkipNode

    def visit_comment(self, node: Element) -> None:
        self.body.append('\n')
        for line in node.astext().splitlines():
            self.body.append('@c %s\n' % line)
        raise nodes.SkipNode

    def visit_problematic(self, node: Element) -> None:
        self.body.append('>>')

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

    def unimplemented_visit(self, node: Element) -> None:
        logger.warning(__('unimplemented node type: %r'), node, location=node)

    def unknown_departure(self, node: Node) -> None:
        pass

    # -- Sphinx specific

    def visit_productionlist(self, node: Element) -> None:
        self.visit_literal_block(None)
        self.in_production_list = True

    def depart_productionlist(self, node: Element) -> None:
        self.in_production_list = False
        self.depart_literal_block(None)

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

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

    def visit_literal_emphasis(self, node: Element) -> None:
        self.body.append('@code{')

    def depart_literal_emphasis(self, node: Element) -> None:
        self.body.append('}')

    def visit_literal_strong(self, node: Element) -> None:
        if self.in_production_list:
            for id_ in node['ids']:
                self.add_anchor(id_, node)
            return
        self.body.append('@code{')

    def depart_literal_strong(self, node: Element) -> None:
        if self.in_production_list:
            return
        self.body.append('}')

    def visit_index(self, node: Element) -> None:
        # terminate the line but don't prevent paragraph breaks
        if isinstance(node.parent, nodes.paragraph):
            self.ensure_eol()
        else:
            self.body.append('\n')
        for _entry_type, value, _target_id, _main, _category_key in node['entries']:
            text = self.escape_menu(value)
            self.body.append('@geindex %s\n' % text)

    def visit_versionmodified(self, node: Element) -> None:
        self.body.append('\n')

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

    def visit_start_of_file(self, node: Element) -> None:
        # add a document target
        self.next_section_ids.add(':doc')
        self.curfilestack.append(node['docname'])
        self.footnotestack.append(self.collect_footnotes(node))

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

    def visit_centered(self, node: Element) -> None:
        txt = self.escape_arg(node.astext())
        self.body.append('\n\n@center %s\n\n' % txt)
        raise nodes.SkipNode

    def visit_seealso(self, node: Element) -> None:
        self.body.append('\n\n@subsubheading %s\n\n' % admonitionlabels['seealso'])

    def depart_seealso(self, node: Element) -> None:
        self.body.append('\n')

    def visit_meta(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:
        bullet_list = cast('nodes.bullet_list', node[0])
        list_items = cast('Iterable[nodes.list_item]', bullet_list)
        self.body.append('\n\n')
        self.body.append(', '.join(n.astext() for n in list_items) + '.')
        self.body.append('\n\n')
        raise nodes.SkipNode

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

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

    def visit_desc(self, node: addnodes.desc) -> None:
        self.descs.append(node)
        self.at_deffnx = '@deffn'

    def depart_desc(self, node: addnodes.desc) -> None:
        self.descs.pop()
        self.ensure_eol()
        self.body.append('@end deffn\n')

    def visit_desc_signature(self, node: Element) -> None:
        self.escape_hyphens += 1
        objtype = node.parent['objtype']
        if objtype != 'describe':
            for id in node.get('ids'):
                self.add_anchor(id, node)
        # use the full name of the objtype for the category
        try:
            domain = self._domains[node.parent['domain']]
            name = domain.get_type_name(
                domain.object_types[objtype], self.config.primary_domain == domain.name
            )
        except KeyError:
            name = objtype
        # by convention, the deffn category should be capitalized like a title
        category = self.escape_arg(smart_capwords(name))
        self.body.append(f'\n{self.at_deffnx} {{{category}}} ')
        self.at_deffnx = '@deffnx'
        self.desc_type_name: str | None = name

    def depart_desc_signature(self, node: Element) -> None:
        self.body.append('\n')
        self.escape_hyphens -= 1
        self.desc_type_name = None

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

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

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

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

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

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

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

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

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

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

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

    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(' -> ')

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

    def visit_desc_parameterlist(self, node: Element) -> None:
        self.body.append(' (')
        self.first_param = 1

    def depart_desc_parameterlist(self, node: Element) -> None:
        self.body.append(')')

    def visit_desc_type_parameter_list(self, node: Element) -> None:
        self.body.append(' [')
        self.first_param = 1

    def depart_desc_type_parameter_list(self, node: Element) -> None:
        self.body.append(']')

    def visit_desc_parameter(self, node: Element) -> None:
        if not self.first_param:
            self.body.append(', ')
        else:
            self.first_param = 0
        text = self.escape(node.astext())
        # replace no-break spaces with normal ones
        text = text.replace('\N{NO-BREAK SPACE}', '@w{ }')
        self.body.append(text)
        raise nodes.SkipNode

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

    def visit_desc_optional(self, node: Element) -> None:
        self.body.append('[')

    def depart_desc_optional(self, node: Element) -> None:
        self.body.append(']')

    def visit_desc_annotation(self, node: Element) -> None:
        # Try to avoid duplicating info already displayed by the deffn category.
        # e.g.
        #     @deffn {Class} Foo
        #     -- instead of --
        #     @deffn {Class} class Foo
        txt = node.astext().strip()
        if (self.descs and txt == self.descs[-1]['objtype']) or (
            self.desc_type_name and txt in self.desc_type_name.split()
        ):
            raise nodes.SkipNode

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

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

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

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

    def visit_abbreviation(self, node: Element) -> None:
        explanation = node.get('explanation', '')
        abbr = node.astext()
        self.body.append('@abbr{')
        if explanation and abbr not in self.handled_abbrs:
            self.context.append(',%s}' % self.escape_arg(explanation))
            self.handled_abbrs.add(abbr)
        else:
            self.context.append('}')

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

    def visit_manpage(self, node: Element) -> None:
        return self.visit_literal_emphasis(node)

    def depart_manpage(self, node: Element) -> None:
        return self.depart_literal_emphasis(node)

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

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

    def visit_hlist(self, node: Element) -> None:
        self.visit_bullet_list(node)

    def depart_hlist(self, node: Element) -> None:
        self.depart_bullet_list(node)

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

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

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

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

    def visit_math(self, node: nodes.math) -> None:
        self.body.append('@math{' + self.escape_arg(node.astext()) + '}')
        raise nodes.SkipNode

    def visit_math_block(self, node: nodes.math_block) -> None:
        if node.get('label'):
            self.add_anchor(node['label'], node)
        self.body.append(
            f'\n\n@example\n{self.escape_arg(node.astext())}\n@end example\n\n'
        )
        raise nodes.SkipNode
