# D-Bus sphinx domain extension
#
# Copyright (C) 2021, Red Hat Inc.
#
# SPDX-License-Identifier: LGPL-2.1-or-later
#
# Author: Marc-André Lureau <marcandre.lureau@redhat.com>

from typing import (
    Any,
    Dict,
    Iterable,
    Iterator,
    List,
    NamedTuple,
    Optional,
    Tuple,
    cast,
)

from docutils import nodes
from docutils.nodes import Element, Node
from docutils.parsers.rst import directives
from sphinx import addnodes
from sphinx.addnodes import desc_signature, pending_xref
from sphinx.directives import ObjectDescription
from sphinx.domains import Domain, Index, IndexEntry, ObjType
from sphinx.locale import _
from sphinx.roles import XRefRole
from sphinx.util import nodes as node_utils
from sphinx.util.docfields import Field, TypedField
from sphinx.util.typing import OptionSpec


class DBusDescription(ObjectDescription[str]):
    """Base class for DBus objects"""

    option_spec: OptionSpec = ObjectDescription.option_spec.copy()
    option_spec.update(
        {
            "deprecated": directives.flag,
        }
    )

    def get_index_text(self, modname: str, name: str) -> str:
        """Return the text for the index entry of the object."""
        raise NotImplementedError("must be implemented in subclasses")

    def add_target_and_index(
        self, name: str, sig: str, signode: desc_signature
    ) -> None:
        ifacename = self.env.ref_context.get("dbus:interface")
        node_id = name
        if ifacename:
            node_id = f"{ifacename}.{node_id}"

        signode["names"].append(name)
        signode["ids"].append(node_id)

        if "noindexentry" not in self.options:
            indextext = self.get_index_text(ifacename, name)
            if indextext:
                self.indexnode["entries"].append(
                    ("single", indextext, node_id, "", None)
                )

        domain = cast(DBusDomain, self.env.get_domain("dbus"))
        domain.note_object(name, self.objtype, node_id, location=signode)


class DBusInterface(DBusDescription):
    """
    Implementation of ``dbus:interface``.
    """

    def get_index_text(self, ifacename: str, name: str) -> str:
        return ifacename

    def before_content(self) -> None:
        self.env.ref_context["dbus:interface"] = self.arguments[0]

    def after_content(self) -> None:
        self.env.ref_context.pop("dbus:interface")

    def handle_signature(self, sig: str, signode: desc_signature) -> str:
        signode += addnodes.desc_annotation("interface ", "interface ")
        signode += addnodes.desc_name(sig, sig)
        return sig

    def run(self) -> List[Node]:
        _, node = super().run()
        name = self.arguments[0]
        section = nodes.section(ids=[name + "-section"])
        section += nodes.title(name, "%s interface" % name)
        section += node
        return [self.indexnode, section]


class DBusMember(DBusDescription):

    signal = False


class DBusMethod(DBusMember):
    """
    Implementation of ``dbus:method``.
    """

    option_spec: OptionSpec = DBusMember.option_spec.copy()
    option_spec.update(
        {
            "noreply": directives.flag,
        }
    )

    doc_field_types: List[Field] = [
        TypedField(
            "arg",
            label=_("Arguments"),
            names=("arg",),
            rolename="arg",
            typerolename=None,
            typenames=("argtype", "type"),
        ),
        TypedField(
            "ret",
            label=_("Returns"),
            names=("ret",),
            rolename="ret",
            typerolename=None,
            typenames=("rettype", "type"),
        ),
    ]

    def get_index_text(self, ifacename: str, name: str) -> str:
        return _("%s() (%s method)") % (name, ifacename)

    def handle_signature(self, sig: str, signode: desc_signature) -> str:
        params = addnodes.desc_parameterlist()
        returns = addnodes.desc_parameterlist()

        contentnode = addnodes.desc_content()
        self.state.nested_parse(self.content, self.content_offset, contentnode)
        for child in contentnode:
            if isinstance(child, nodes.field_list):
                for field in child:
                    ty, sg, name = field[0].astext().split(None, 2)
                    param = addnodes.desc_parameter()
                    param += addnodes.desc_sig_keyword_type(sg, sg)
                    param += addnodes.desc_sig_space()
                    param += addnodes.desc_sig_name(name, name)
                    if ty == "arg":
                        params += param
                    elif ty == "ret":
                        returns += param

        anno = "signal " if self.signal else "method "
        signode += addnodes.desc_annotation(anno, anno)
        signode += addnodes.desc_name(sig, sig)
        signode += params
        if not self.signal and "noreply" not in self.options:
            ret = addnodes.desc_returns()
            ret += returns
            signode += ret

        return sig


class DBusSignal(DBusMethod):
    """
    Implementation of ``dbus:signal``.
    """

    doc_field_types: List[Field] = [
        TypedField(
            "arg",
            label=_("Arguments"),
            names=("arg",),
            rolename="arg",
            typerolename=None,
            typenames=("argtype", "type"),
        ),
    ]
    signal = True

    def get_index_text(self, ifacename: str, name: str) -> str:
        return _("%s() (%s signal)") % (name, ifacename)


class DBusProperty(DBusMember):
    """
    Implementation of ``dbus:property``.
    """

    option_spec: OptionSpec = DBusMember.option_spec.copy()
    option_spec.update(
        {
            "type": directives.unchanged,
            "readonly": directives.flag,
            "writeonly": directives.flag,
            "readwrite": directives.flag,
            "emits-changed": directives.unchanged,
        }
    )

    doc_field_types: List[Field] = []

    def get_index_text(self, ifacename: str, name: str) -> str:
        return _("%s (%s property)") % (name, ifacename)

    def transform_content(self, contentnode: addnodes.desc_content) -> None:
        fieldlist = nodes.field_list()
        access = None
        if "readonly" in self.options:
            access = _("read-only")
        if "writeonly" in self.options:
            access = _("write-only")
        if "readwrite" in self.options:
            access = _("read & write")
        if access:
            content = nodes.Text(access)
            fieldname = nodes.field_name("", _("Access"))
            fieldbody = nodes.field_body("", nodes.paragraph("", "", content))
            field = nodes.field("", fieldname, fieldbody)
            fieldlist += field
        emits = self.options.get("emits-changed", None)
        if emits:
            content = nodes.Text(emits)
            fieldname = nodes.field_name("", _("Emits Changed"))
            fieldbody = nodes.field_body("", nodes.paragraph("", "", content))
            field = nodes.field("", fieldname, fieldbody)
            fieldlist += field
        if len(fieldlist) > 0:
            contentnode.insert(0, fieldlist)

    def handle_signature(self, sig: str, signode: desc_signature) -> str:
        contentnode = addnodes.desc_content()
        self.state.nested_parse(self.content, self.content_offset, contentnode)
        ty = self.options.get("type")

        signode += addnodes.desc_annotation("property ", "property ")
        signode += addnodes.desc_name(sig, sig)
        signode += addnodes.desc_sig_punctuation("", ":")
        signode += addnodes.desc_sig_keyword_type(ty, ty)
        return sig

    def run(self) -> List[Node]:
        self.name = "dbus:member"
        return super().run()


class DBusXRef(XRefRole):
    def process_link(self, env, refnode, has_explicit_title, title, target):
        refnode["dbus:interface"] = env.ref_context.get("dbus:interface")
        if not has_explicit_title:
            title = title.lstrip(".")  # only has a meaning for the target
            target = target.lstrip("~")  # only has a meaning for the title
            # if the first character is a tilde, don't display the module/class
            # parts of the contents
            if title[0:1] == "~":
                title = title[1:]
                dot = title.rfind(".")
                if dot != -1:
                    title = title[dot + 1 :]
        # if the first character is a dot, search more specific namespaces first
        # else search builtins first
        if target[0:1] == ".":
            target = target[1:]
            refnode["refspecific"] = True
        return title, target


class DBusIndex(Index):
    """
    Index subclass to provide a D-Bus interfaces index.
    """

    name = "dbusindex"
    localname = _("D-Bus Interfaces Index")
    shortname = _("dbus")

    def generate(
        self, docnames: Iterable[str] = None
    ) -> Tuple[List[Tuple[str, List[IndexEntry]]], bool]:
        content: Dict[str, List[IndexEntry]] = {}
        # list of prefixes to ignore
        ignores: List[str] = self.domain.env.config["dbus_index_common_prefix"]
        ignores = sorted(ignores, key=len, reverse=True)

        ifaces = sorted(
            [
                x
                for x in self.domain.data["objects"].items()
                if x[1].objtype == "interface"
            ],
            key=lambda x: x[0].lower(),
        )
        for name, (docname, node_id, _) in ifaces:
            if docnames and docname not in docnames:
                continue

            for ignore in ignores:
                if name.startswith(ignore):
                    name = name[len(ignore) :]
                    stripped = ignore
                    break
            else:
                stripped = ""

            entries = content.setdefault(name[0].lower(), [])
            entries.append(IndexEntry(stripped + name, 0, docname, node_id, "", "", ""))

        # sort by first letter
        sorted_content = sorted(content.items())

        return sorted_content, False


class ObjectEntry(NamedTuple):
    docname: str
    node_id: str
    objtype: str


class DBusDomain(Domain):
    """
    Implementation of the D-Bus domain.
    """

    name = "dbus"
    label = "D-Bus"
    object_types: Dict[str, ObjType] = {
        "interface": ObjType(_("interface"), "iface", "obj"),
        "method": ObjType(_("method"), "meth", "obj"),
        "signal": ObjType(_("signal"), "sig", "obj"),
        "property": ObjType(_("property"), "attr", "_prop", "obj"),
    }
    directives = {
        "interface": DBusInterface,
        "method": DBusMethod,
        "signal": DBusSignal,
        "property": DBusProperty,
    }
    roles = {
        "iface": DBusXRef(),
        "meth": DBusXRef(),
        "sig": DBusXRef(),
        "prop": DBusXRef(),
    }
    initial_data: Dict[str, Dict[str, Tuple[Any]]] = {
        "objects": {},  # fullname -> ObjectEntry
    }
    indices = [
        DBusIndex,
    ]

    @property
    def objects(self) -> Dict[str, ObjectEntry]:
        return self.data.setdefault("objects", {})  # fullname -> ObjectEntry

    def note_object(
        self, name: str, objtype: str, node_id: str, location: Any = None
    ) -> None:
        self.objects[name] = ObjectEntry(self.env.docname, node_id, objtype)

    def clear_doc(self, docname: str) -> None:
        for fullname, obj in list(self.objects.items()):
            if obj.docname == docname:
                del self.objects[fullname]

    def find_obj(self, typ: str, name: str) -> Optional[Tuple[str, ObjectEntry]]:
        # skip parens
        if name[-2:] == "()":
            name = name[:-2]
        if typ in ("meth", "sig", "prop"):
            try:
                ifacename, name = name.rsplit(".", 1)
            except ValueError:
                pass
        return self.objects.get(name)

    def resolve_xref(
        self,
        env: "BuildEnvironment",
        fromdocname: str,
        builder: "Builder",
        typ: str,
        target: str,
        node: pending_xref,
        contnode: Element,
    ) -> Optional[Element]:
        """Resolve the pending_xref *node* with the given *typ* and *target*."""
        objdef = self.find_obj(typ, target)
        if objdef:
            return node_utils.make_refnode(
                builder, fromdocname, objdef.docname, objdef.node_id, contnode
            )

    def get_objects(self) -> Iterator[Tuple[str, str, str, str, str, int]]:
        for refname, obj in self.objects.items():
            yield (refname, refname, obj.objtype, obj.docname, obj.node_id, 1)

    def merge_domaindata(self, docnames, otherdata):
        for name, obj in otherdata['objects'].items():
            if obj.docname in docnames:
                self.data['objects'][name] = obj

def setup(app):
    app.add_domain(DBusDomain)
    app.add_config_value("dbus_index_common_prefix", [], "env")