"""Support for Sphinx domains."""
import pathlib
from typing import Any
from typing import Dict
from typing import Iterable
from typing import List
from typing import Optional
from typing import Set
from typing import Tuple
from typing import Type

import pygls.uris as Uri
from docutils import nodes
from docutils.parsers.rst import Directive
from lsprotocol.types import CompletionItem
from lsprotocol.types import CompletionItemKind
from lsprotocol.types import Location
from lsprotocol.types import Position
from lsprotocol.types import Range
from pygls.workspace import Document
from sphinx.domains import Domain

from esbonio.lsp import CompletionContext
from esbonio.lsp import DefinitionContext
from esbonio.lsp import DocumentLinkContext
from esbonio.lsp.directives import DirectiveLanguageFeature
from esbonio.lsp.directives import Directives
from esbonio.lsp.roles import RoleLanguageFeature
from esbonio.lsp.roles import Roles
from esbonio.lsp.sphinx import SphinxLanguageServer

TARGET_KINDS = {
    "attribute": CompletionItemKind.Field,
    "doc": CompletionItemKind.File,
    "class": CompletionItemKind.Class,
    "envvar": CompletionItemKind.Variable,
    "function": CompletionItemKind.Function,
    "method": CompletionItemKind.Method,
    "module": CompletionItemKind.Module,
    "term": CompletionItemKind.Text,
}


class DomainHelpers:
    """Common methods that work on domains."""

    rst: SphinxLanguageServer

    @property
    def domains(self) -> Dict[str, Domain]:
        """Return a dictionary of known domains."""

        if self.rst.app is None or self.rst.app.env is None:
            return dict()

        return self.rst.app.env.domains  # type: ignore

    def get_default_domain(self, uri: str) -> Optional[str]:
        """Return the default domain for the given uri."""

        # TODO: Add support for .. default-domain::
        if self.rst.app is not None:
            return self.rst.app.config.primary_domain

        return None


class DomainDirectives(DirectiveLanguageFeature, DomainHelpers):
    """Support for directives coming from Sphinx's domains."""

    def __init__(self, rst: SphinxLanguageServer):
        self.rst = rst

        self._directives: Optional[Dict[str, Type[Directive]]] = None
        """Cache for known directives."""

    @property
    def directives(self) -> Dict[str, Type[Directive]]:
        if self._directives is not None:
            return self._directives

        directives = {}
        for prefix, domain in self.domains.items():
            for name, directive in domain.directives.items():
                directives[f"{prefix}:{name}"] = directive

        self._directives = directives
        return self._directives

    def get_implementation(
        self, directive: str, domain: Optional[str]
    ) -> Optional[Type[Directive]]:
        if domain is not None:
            return self.directives.get(f"{domain}:{directive}", None)

        if self.rst.app is None:
            return None

        # Try the default domain
        primary_domain = self.rst.app.config.primary_domain
        impl = self.directives.get(f"{primary_domain}:{directive}", None)
        if impl is not None:
            return impl

        # Try the std domain
        return self.directives.get(f"std:{directive}", None)

    def index_directives(self) -> Dict[str, Type[Directive]]:
        return self.directives

    def suggest_directives(
        self, context: CompletionContext
    ) -> Iterable[Tuple[str, Type[Directive]]]:
        # In addition to providing each directive fully qualified, we should provide a
        # suggestion for directives in the std and primary domains without the prefix.
        items = self.directives.copy()
        primary_domain = self.get_default_domain(context.doc.uri)

        for key, directive in self.directives.items():
            if key.startswith("std:"):
                items[key.replace("std:", "")] = directive
                continue

            if primary_domain and key.startswith(f"{primary_domain}:"):
                items[key.replace(f"{primary_domain}:", "")] = directive

        return items.items()

    def suggest_options(
        self, context: CompletionContext, directive: str, domain: Optional[str]
    ) -> Iterable[str]:
        impl = self.get_implementation(directive, domain)
        if impl is None:
            return []

        option_spec = getattr(impl, "option_spec", {})
        return option_spec.keys()


class DomainRoles(RoleLanguageFeature, DomainHelpers):
    """Support for roles coming from Sphinx's domains."""

    def __init__(self, rst: SphinxLanguageServer):
        self.rst = rst

        self._roles: Optional[Dict[str, Any]] = None
        """Cache for known roles."""

        self._role_target_types: Optional[Dict[str, List[str]]] = None
        """Cache for role target types."""

    @property
    def roles(self) -> Dict[str, Any]:
        if self._roles is not None:
            return self._roles

        roles = {}
        for prefix, domain in self.domains.items():
            for name, role in domain.roles.items():
                roles[f"{prefix}:{name}"] = role

        self._roles = roles
        return self._roles

    @property
    def role_target_types(self) -> Dict[str, List[str]]:
        if self._role_target_types is not None:
            return self._role_target_types

        self._role_target_types = {}

        for prefix, domain in self.domains.items():
            fmt = "{prefix}:{name}" if prefix else "{name}"

            for name, item_type in domain.object_types.items():
                for role in item_type.roles:
                    role_key = fmt.format(name=role, prefix=prefix)
                    target_types = self._role_target_types.get(role_key, list())
                    target_types.append(name)

                    self._role_target_types[role_key] = target_types

        return self._role_target_types

    def _get_role_target_types(self, name: str, domain: str = "") -> List[str]:
        """Return a list indicating which object types a role is capable of linking
        with.
        """

        if domain:
            return self.role_target_types.get(f"{domain}:{name}", [])

        # Try the primary domain
        if self.rst.app and self.rst.app.config.primary_domain:
            key = f"{self.rst.app.config.primary_domain}:{name}"

            if key in self.role_target_types:
                return self.role_target_types[key]

        # Finally try the standard domain
        return self.role_target_types.get(f"std:{name}", [])

    def _get_role_targets(self, name: str, domain: str = "") -> List[tuple]:
        """Return a list of objects targeted by the given role.

        Parameters
        ----------
        name:
           The name of the role
        domain:
           The domain the role is a part of, if applicable.
        """

        targets: List[tuple] = []
        domain_obj: Optional[Domain] = None

        if domain:
            domain_obj = self.domains.get(domain, None)
        else:
            std = self.domains.get("std", None)
            if std and name in std.roles:
                domain_obj = std

            elif self.rst.app and self.rst.app.config.primary_domain:
                domain_obj = self.domains.get(self.rst.app.config.primary_domain, None)

        target_types = set(self._get_role_target_types(name, domain))

        if not domain_obj:
            self.rst.logger.debug(
                "Unable to find domain for role '%s:%s'", domain, name
            )
            return []

        for obj in domain_obj.get_objects():
            if obj[2] in target_types:
                targets.append(obj)

        return targets

    def get_implementation(
        self, role: str, domain: Optional[str]
    ) -> Optional[Directive]:
        if domain is not None:
            return self.roles.get(f"{domain}:{role}", None)

        if self.rst.app is None:
            return None

        # Try the default domain
        primary_domain = self.rst.app.config.primary_domain
        impl = self.roles.get(f"{primary_domain}:{role}", None)
        if impl is not None:
            return impl

        # Try the std domain
        return self.roles.get(f"std:{role}", None)

    def index_roles(self) -> Dict[str, Any]:
        return self.roles

    def suggest_roles(self, context: CompletionContext) -> Iterable[Tuple[str, Any]]:
        # In addition to providing each role fully qulaified, we should provide a
        # suggestion for directives in the std and primary domains without the prefix.
        items = self.roles.copy()
        primary_domain = self.get_default_domain(context.doc.uri)

        for key, role in self.roles.items():
            if key.startswith("std:"):
                items[key.replace("std:", "")] = role
                continue

            if primary_domain and key.startswith(f"{primary_domain}:"):
                items[key.replace(f"{primary_domain}:", "")] = role

        return items.items()

    def complete_targets(
        self, context: CompletionContext, name: str, domain: str
    ) -> List[CompletionItem]:
        label = context.match.group("label")

        # Intersphinx targets contain ':' characters.
        if ":" in label:
            return []

        return [
            object_to_completion_item(obj)
            for obj in self._get_role_targets(name, domain)
        ]

    def resolve_target_link(
        self, context: DocumentLinkContext, name: str, domain: Optional[str], label: str
    ) -> Tuple[Optional[str], Optional[str]]:
        """``textDocument/documentLink`` support"""

        # Ignore intersphinx references.
        if ":" in label:
            return None, None

        # Other roles like :ref: do not make sense as the ``textDocument/documentLink``
        # api doesn't support specific locations like goto definition does.
        if (domain is not None and domain != "std") or name != "doc":
            return None, None

        return self.resolve_doc(context.doc, label), None

    def find_target_definitions(
        self, context: DefinitionContext, name: str, domain: str, label: str
    ) -> List[Location]:
        if not domain and name == "ref":
            return self.ref_definition(label)

        if not domain and name == "doc":
            return self.doc_definition(context.doc, label)

        return []

    def resolve_doc(self, doc: Document, label: str) -> Optional[str]:
        if self.rst.app is None:
            return None

        srcdir = self.rst.app.srcdir
        currentdir = pathlib.Path(Uri.to_fs_path(doc.uri)).parent

        if label.startswith("/"):
            path = pathlib.Path(srcdir, label[1:] + ".rst")
        else:
            path = pathlib.Path(currentdir, label + ".rst")

        if not path.exists():
            return None

        return Uri.from_fs_path(str(path))

    def doc_definition(self, doc: Document, label: str) -> List[Location]:
        """Goto definition implementation for ``:doc:`` targets"""

        uri = self.resolve_doc(doc, label)
        if not uri:
            return []

        return [
            Location(
                uri=uri,
                range=Range(
                    start=Position(line=0, character=0),
                    end=Position(line=1, character=0),
                ),
            )
        ]

    def ref_definition(self, label: str) -> List[Location]:
        """Goto definition implementation for ``:ref:`` targets"""

        if not self.rst.app or not self.rst.app.env:
            return []

        types = set(self._get_role_target_types("ref"))
        std = self.domains["std"]
        if std is None:
            return []

        docname = self.find_docname_for_label(label, std, types)
        if docname is None:
            return []

        path = self.rst.app.env.doc2path(docname)
        uri = Uri.from_fs_path(path)

        doctree = self.rst.get_initial_doctree(uri)
        if doctree is None:
            return []

        uri = None
        line = None

        for node in doctree.traverse(condition=nodes.target):
            if "refid" not in node:
                continue

            if doctree.nameids.get(label, "") == node["refid"]:
                uri = Uri.from_fs_path(node.source)
                line = node.line
                break

        if uri is None or line is None:
            return []

        return [
            Location(
                uri=uri,
                range=Range(
                    start=Position(line=line - 1, character=0),
                    end=Position(line=line, character=0),
                ),
            )
        ]

    def find_docname_for_label(
        self, label: str, domain: Domain, types: Optional[Set[str]] = None
    ) -> Optional[str]:
        """Given the label name and domain it belongs to, return the docname its
        definition resides in.

        Parameters
        ----------
        label
           The label to search for

        domain
           The domain to search within

        types
           A collection of object types that the label chould have.
        """

        docname = None
        types = types or set()

        # _, title, _, _, anchor, priority
        for name, _, type_, doc, _, _ in domain.get_objects():
            if types and type_ not in types:
                continue

            if name == label:
                docname = doc
                break

        return docname


class Intersphinx(RoleLanguageFeature):
    def __init__(self, rst: SphinxLanguageServer, domain: DomainRoles):
        self.rst = rst
        self.domain = domain

    def complete_targets(
        self, context: CompletionContext, name: str, domain: str
    ) -> List[CompletionItem]:
        label = context.match.group("label")

        # Intersphinx targets contain ':' characters.
        if ":" in label:
            return self.complete_intersphinx_targets(name, domain, label)

        return self.complete_intersphinx_projects(name, domain)

    def complete_intersphinx_projects(
        self, name: str, domain: str
    ) -> List[CompletionItem]:
        items = []
        for project in self.get_intersphinx_projects():
            if not self.has_intersphinx_targets(project, name, domain):
                continue

            items.append(
                CompletionItem(
                    label=project, detail="intersphinx", kind=CompletionItemKind.Module
                )
            )

        return items

    def complete_intersphinx_targets(
        self, name: str, domain: str, label: str
    ) -> List[CompletionItem]:
        items = []
        project, *_ = label.split(":")
        intersphinx_targets = self.get_intersphinx_targets(project, name, domain)

        for type_, targets in intersphinx_targets.items():
            items += [
                intersphinx_target_to_completion_item(project, label, target, type_)
                for label, target in targets.items()
            ]

        return items

    def resolve_target_link(
        self, context: DocumentLinkContext, name: str, domain: Optional[str], label: str
    ) -> Tuple[Optional[str], Optional[str]]:
        if not self.rst.app:
            return None, None

        project, *parts = label.split(":")
        label = ":".join(parts)
        targets = self.get_intersphinx_targets(project, name, domain or "")

        for _, items in targets.items():
            if label in items:
                source, version, url, display = items[label]
                name = label if display == "-" else display
                tooltip = f"{name} - {source} v{version}"

                return url, tooltip

        return None, None

    def get_intersphinx_projects(self) -> List[str]:
        """Return the list of configured intersphinx project names."""

        if self.rst.app is None:
            return []

        inv = getattr(self.rst.app.env, "intersphinx_named_inventory", {})
        return list(inv.keys())

    def has_intersphinx_targets(
        self, project: str, name: str, domain: str = ""
    ) -> bool:
        """Return ``True`` if the given intersphinx project has targets targeted by the
        given role.

        Parameters
        ----------
        project
           The project to check

        name
           The name of the role

        domain
           The domain the role is a part of, if applicable.
        """
        targets = self.get_intersphinx_targets(project, name, domain)

        if len(targets) == 0:
            return False

        return any([len(items) > 0 for items in targets.values()])

    def get_intersphinx_targets(
        self, project: str, name: str, domain: str = ""
    ) -> Dict[str, Dict[str, tuple]]:
        """Return the intersphinx objects targeted by the given role.

        Parameters
        ----------
        project
           The project to return targets from

        name
           The name of the role

        domain
           The domain the role is a part of, if applicable.
        """

        if self.rst.app is None:
            return {}

        inv = getattr(self.rst.app.env, "intersphinx_named_inventory", {})
        if project not in inv:
            return {}

        targets = {}
        inv = inv[project]

        for target_type in self.domain._get_role_target_types(name, domain):
            explicit_domain = f"{domain}:{target_type}"
            if explicit_domain in inv:
                targets[target_type] = inv[explicit_domain]
                continue

            primary_domain = f'{self.rst.app.config.primary_domain or ""}:{target_type}'
            if primary_domain in inv:
                targets[target_type] = inv[primary_domain]
                continue

            std_domain = f"std:{target_type}"
            if std_domain in inv:
                targets[target_type] = inv[std_domain]

        return targets


def intersphinx_target_to_completion_item(
    project: str, label: str, target: tuple, type_: str
) -> CompletionItem:
    # _. _. url, _
    source, version, _, display = target

    display_name = label if display == "-" else display
    completion_kind = ":".join(type_.split(":")[1:]) if ":" in type_ else type_

    if version:
        version = f" v{version}"

    return CompletionItem(
        label=label,
        detail=f"{display_name} - {source}{version}",
        kind=TARGET_KINDS.get(completion_kind, CompletionItemKind.Reference),
        insert_text=f"{project}:{label}",
    )


def object_to_completion_item(object_: tuple) -> CompletionItem:
    # _, _, _, docname, anchor, priority
    name, display_name, type_, _, _, _ = object_
    insert_text = name

    key = type_.split(":")[1] if ":" in type_ else type_
    kind = TARGET_KINDS.get(key, CompletionItemKind.Reference)

    # ensure :doc: targets are inserted as an absolute path - that way the reference
    # will always work regardless of the file's location.
    if type_ == "doc":
        insert_text = f"/{name}"

    # :option: targets need to be inserted as `<progname> <option>` in order to resolve
    # correctly. However, this only seems to be the case "locally" as
    # `<progname>.<option>` seems to resolve fine when using intersphinx...
    if type_ == "cmdoption":
        name = " ".join(name.split("."))
        display_name = name
        insert_text = name

    return CompletionItem(
        label=name, kind=kind, detail=str(display_name), insert_text=insert_text
    )


def esbonio_setup(rst: SphinxLanguageServer, directives: Directives, roles: Roles):
    directives.add_feature(DomainDirectives(rst))

    domain_roles = DomainRoles(rst)
    intersphinx = Intersphinx(rst, domain_roles)

    roles.add_feature(domain_roles)
    roles.add_feature(intersphinx)
