"""A post-transform for overriding the behaviour of sphinx reference resolution.

This is applied to MyST type references only, such as ``[text](target)``,
and allows for nested syntax
"""
import os
from typing import Any, List, Optional, Tuple, cast

from docutils import nodes
from docutils.nodes import Element, document
from sphinx import addnodes, version_info
from sphinx.addnodes import pending_xref
from sphinx.domains.std import StandardDomain
from sphinx.locale import __
from sphinx.transforms.post_transforms import ReferencesResolver
from sphinx.util import docname_join, logging
from sphinx.util.nodes import clean_astext, make_refnode

from myst_parser._compat import findall

try:
    from sphinx.errors import NoUri
except ImportError:
    # sphinx < 2.1
    from sphinx.environment import NoUri  # type: ignore

logger = logging.getLogger(__name__)


class MystReferenceResolver(ReferencesResolver):
    """Resolves cross-references on doctrees.

    Overrides default sphinx implementation, to allow for nested syntax
    """

    default_priority = 9  # higher priority than ReferencesResolver (10)

    def run(self, **kwargs: Any) -> None:
        self.document: document
        for node in findall(self.document)(addnodes.pending_xref):
            if node["reftype"] != "myst":
                continue

            contnode = cast(nodes.TextElement, node[0].deepcopy())
            newnode = None

            target = node["reftarget"]
            refdoc = node.get("refdoc", self.env.docname)
            domain = None

            try:
                newnode = self.resolve_myst_ref(refdoc, node, contnode)
                if newnode is None:
                    # no new node found? try the missing-reference event
                    # but first we change the the reftype to 'any'
                    # this means it is picked up by extensions like intersphinx
                    node["reftype"] = "any"
                    try:
                        newnode = self.app.emit_firstresult(
                            "missing-reference",
                            self.env,
                            node,
                            contnode,
                            **(
                                {"allowed_exceptions": (NoUri,)}
                                if version_info[0] > 2
                                else {}
                            ),
                        )
                    finally:
                        node["reftype"] = "myst"
                    # still not found? warn if node wishes to be warned about or
                    # we are in nit-picky mode
                    if newnode is None:
                        node["refdomain"] = ""
                        # TODO ideally we would override the warning message here,
                        # to show the [ref.myst] for suppressing warning
                        self.warn_missing_reference(
                            refdoc, node["reftype"], target, node, domain
                        )
            except NoUri:
                newnode = contnode

            node.replace_self(newnode or contnode)

    def resolve_myst_ref(
        self, refdoc: str, node: pending_xref, contnode: Element
    ) -> Element:
        """Resolve reference generated by the "myst" role; ``[text](reference)``.

        This builds on the sphinx ``any`` role to also resolve:

        - Document references with extensions; ``[text](./doc.md)``
        - Document references with anchors with anchors; ``[text](./doc.md#target)``
        - Nested syntax for explicit text with std:doc and std:ref;
          ``[**nested**](reference)``

        """
        target = node["reftarget"]  # type: str
        results = []  # type: List[Tuple[str, Element]]

        res_anchor = self._resolve_anchor(node, refdoc)
        if res_anchor:
            results.append(("std:doc", res_anchor))
        else:
            # if we've already found an anchored doc,
            # don't search in the std:ref/std:doc (leads to duplication)

            # resolve standard references
            res = self._resolve_ref_nested(node, refdoc)
            if res:
                results.append(("std:ref", res))

            # resolve doc names
            res = self._resolve_doc_nested(node, refdoc)
            if res:
                results.append(("std:doc", res))

        # get allowed domains for referencing
        ref_domains = self.env.config.myst_ref_domains

        assert self.app.builder

        # next resolve for any other standard reference objects
        if ref_domains is None or "std" in ref_domains:
            stddomain = cast(StandardDomain, self.env.get_domain("std"))
            for objtype in stddomain.object_types:
                key = (objtype, target)
                if objtype == "term":
                    key = (objtype, target.lower())
                if key in stddomain.objects:
                    docname, labelid = stddomain.objects[key]
                    domain_role = "std:" + stddomain.role_for_objtype(objtype)
                    ref_node = make_refnode(
                        self.app.builder, refdoc, docname, labelid, contnode
                    )
                    results.append((domain_role, ref_node))

        # finally resolve for any other type of allowed reference domain
        for domain in self.env.domains.values():
            if domain.name == "std":
                continue  # we did this one already
            if ref_domains is not None and domain.name not in ref_domains:
                continue
            try:
                results.extend(
                    domain.resolve_any_xref(
                        self.env, refdoc, self.app.builder, target, node, contnode
                    )
                )
            except NotImplementedError:
                # the domain doesn't yet support the new interface
                # we have to manually collect possible references (SLOW)
                if not (getattr(domain, "__module__", "").startswith("sphinx.")):
                    logger.warning(
                        f"Domain '{domain.__module__}::{domain.name}' has not "
                        "implemented a `resolve_any_xref` method [myst.domains]",
                        type="myst",
                        subtype="domains",
                        once=True,
                    )
                for role in domain.roles:
                    res = domain.resolve_xref(
                        self.env, refdoc, self.app.builder, role, target, node, contnode
                    )
                    if res and len(res) and isinstance(res[0], nodes.Element):
                        results.append((f"{domain.name}:{role}", res))

        # now, see how many matches we got...
        if not results:
            return None
        if len(results) > 1:

            def stringify(name, node):
                reftitle = node.get("reftitle", node.astext())
                return f":{name}:`{reftitle}`"

            candidates = " or ".join(stringify(name, role) for name, role in results)
            logger.warning(
                __(
                    f"more than one target found for 'myst' cross-reference {target}: "
                    f"could be {candidates} [myst.ref]"
                ),
                location=node,
                type="myst",
                subtype="ref",
            )

        res_role, newnode = results[0]
        # Override "myst" class with the actual role type to get the styling
        # approximately correct.
        res_domain = res_role.split(":")[0]
        if len(newnode) > 0 and isinstance(newnode[0], nodes.Element):
            newnode[0]["classes"] = newnode[0].get("classes", []) + [
                res_domain,
                res_role.replace(":", "-"),
            ]

        return newnode

    def _resolve_anchor(
        self, node: pending_xref, fromdocname: str
    ) -> Optional[Element]:
        """Resolve doc with anchor."""
        if self.env.config.myst_heading_anchors is None:
            # no target anchors will have been created, so we don't look for them
            return None
        target = node["reftarget"]  # type: str
        if "#" not in target:
            return None
        # the link may be a heading anchor; we need to first get the relative path
        rel_path, anchor = target.rsplit("#", 1)
        rel_path = os.path.normpath(rel_path)
        if rel_path == ".":
            # anchor in the same doc as the node
            doc_path = self.env.doc2path(node.get("refdoc", fromdocname), base=False)
        else:
            # anchor in a different doc from the node
            doc_path = os.path.normpath(
                os.path.join(node.get("refdoc", fromdocname), "..", rel_path)
            )
        return self._resolve_ref_nested(node, fromdocname, doc_path + "#" + anchor)

    def _resolve_ref_nested(
        self, node: pending_xref, fromdocname: str, target=None
    ) -> Optional[Element]:
        """This is the same as ``sphinx.domains.std._resolve_ref_xref``,
        but allows for nested syntax, rather than converting the inner node to raw text.
        """
        stddomain = cast(StandardDomain, self.env.get_domain("std"))
        target = target or node["reftarget"].lower()

        if node["refexplicit"]:
            # reference to anonymous label; the reference uses
            # the supplied link caption
            docname, labelid = stddomain.anonlabels.get(target, ("", ""))
            sectname = node.astext()
            innernode = nodes.inline(sectname, "")
            innernode.extend(node[0].children)
        else:
            # reference to named label; the final node will
            # contain the section name after the label
            docname, labelid, sectname = stddomain.labels.get(target, ("", "", ""))
            innernode = nodes.inline(sectname, sectname)

        if not docname:
            return None

        assert self.app.builder
        return make_refnode(self.app.builder, fromdocname, docname, labelid, innernode)

    def _resolve_doc_nested(
        self, node: pending_xref, fromdocname: str
    ) -> Optional[Element]:
        """This is the same as ``sphinx.domains.std._resolve_doc_xref``,
        but allows for nested syntax, rather than converting the inner node to raw text.

        It also allows for extensions on document names.
        """
        # directly reference to document by source name; can be absolute or relative
        refdoc = node.get("refdoc", fromdocname)
        docname = docname_join(refdoc, node["reftarget"])

        if docname not in self.env.all_docs:
            # try stripping known extensions from doc name
            if os.path.splitext(docname)[1] in self.env.config.source_suffix:
                docname = os.path.splitext(docname)[0]
            if docname not in self.env.all_docs:
                return None

        if node["refexplicit"]:
            # reference with explicit title
            caption = node.astext()
            innernode = nodes.inline(caption, "", classes=["doc"])
            innernode.extend(node[0].children)
        else:
            # TODO do we want nested syntax for titles?
            caption = clean_astext(self.env.titles[docname])
            innernode = nodes.inline(caption, caption, classes=["doc"])

        assert self.app.builder
        return make_refnode(self.app.builder, fromdocname, docname, "", innernode)
