"""A Sphinx extension for linking to your project's issue tracker."""
import re
from typing import Callable, Optional, Tuple

from docutils import nodes, utils
from sphinx.config import Config
from sphinx.util.nodes import split_explicit_title

__version__ = "3.0.1"
__author__ = "Steven Loria"
__license__ = "MIT"


def cve_role(name, rawtext, text, lineno, inliner, options=None, content=None):
    """Sphinx role for linking to a CVE on https://cve.mitre.org.

    Examples: ::

        :cve:`CVE-2018-17175`

    """
    options = options or {}
    content = content or []
    has_explicit_title, title, target = split_explicit_title(text)

    target = utils.unescape(target).strip()
    title = utils.unescape(title).strip()
    ref = f"https://cve.mitre.org/cgi-bin/cvename.cgi?name={target}"
    text = title if has_explicit_title else target
    link = nodes.reference(text=text, refuri=ref, **options)
    return [link], []


GITHUB_USER_RE = re.compile("^https://github.com/([^/]+)/([^/]+)/.*")


def _get_default_group_and_project(
    config: Config, uri_config_option: str
) -> Optional[Tuple[str, str]]:
    """
    Get the default group/project or None if not set
    """
    old_config = getattr(config, "issues_github_path", None)
    new_config = getattr(config, "issues_default_group_project", None)

    if old_config and new_config:
        raise ValueError(
            "Both 'issues_github_path' and 'issues_default_group_project' are set, even"
            " though they define the same setting.  "
            "Please only define one of these."
        )
    group_and_project = new_config or old_config

    if group_and_project:
        assert isinstance(group_and_project, str)
        try:
            group, project = group_and_project.split("/", maxsplit=1)
            return group, project
        except ValueError as e:
            raise ValueError(
                "`issues_github_path` or `issues_default_group_project` needs to "
                "define a value in the form of `<group or user>/<project>` "
                f"but `{config}` was given."
            ) from e

    # If group and project was not set, we need to look for it within the github url
    # for backward compatibility
    if not group_and_project:
        uri = getattr(config, uri_config_option)
        if uri:
            match = GITHUB_USER_RE.match(uri)
            if match:
                return match.groups()[0], match.groups()[1]

    return None


def _get_placeholder(uri_config_option: str) -> str:
    """
    Get the placeholder from the uri_config_option
    """
    try:
        # i.e. issues_pr_uri -> pr
        return uri_config_option[:-4].split("_", maxsplit=1)[1]
    except IndexError:
        # issues_uri -> issue
        return uri_config_option[:-5]


def _get_uri_template(
    config: Config,
    uri_config_option: str,
) -> str:
    """
    Get a URL format template that can be filled with user information based
    on the given configuration

    The result always contains the following placeholder
      - n (the issue number, user, pull request, etc...)

    The result can contain the following other placeholders
      - group (same as user in github)
      - project

    Examples for possible results:

         - "https://github.com/{group}/{project}/issues/{n}"

         - "https://gitlab.company.com/{group}/{project}/{n}"

         - "https://fancy.issuetrack.com?group={group}&project={project}&issue={n}"

    Raises:
         - ValueError if the given uri contains an invalid placeholder
    """
    format_string = str(getattr(config, uri_config_option))
    placeholder = _get_placeholder(uri_config_option)

    result = format_string.replace(f"{{{placeholder}}}", "{n}")

    try:
        result.format(project="", group="", n="")
    except (NameError, KeyError) as e:
        raise ValueError(
            f"The `{uri_config_option}` option contains invalid placeholders. "
            f"Only {{group}}, {{projects}} and {{{placeholder}}} are allowed."
            f'Invalid format string: "{format_string}".'
        ) from e
    return result


def _get_uri(
    uri_config_option: str,
    config: Config,
    number: str,
    group_and_project: Optional[Tuple[str, str]] = None,
) -> str:
    """
    Get a URI based on the given configuration and do some sanity checking
    """
    format_string = _get_uri_template(config, uri_config_option)

    url_vars = {"n": number}

    config_group_and_project = _get_default_group_and_project(config, uri_config_option)
    if group_and_project:
        # Group and Project defined by call
        if config_group_and_project:
            to_replace = "/".join(config_group_and_project)
            if to_replace in format_string:
                # Backward compatibility, replace default group/project
                # with {group}/{project}
                format_string = format_string.replace(to_replace, "{group}/{project}")
        (url_vars["group"], url_vars["project"]) = group_and_project
    elif config_group_and_project:
        # If not defined by call use the default if given
        (url_vars["group"], url_vars["project"]) = config_group_and_project

    try:
        return format_string.format(**url_vars)
    except (NameError, KeyError) as e:
        # The format string was checked before, that it contains no additional not
        # supported placeholders. So this occur
        raise ValueError(
            f"The `{uri_config_option}` format `{format_string}` requires a "
            f"group/project to be defined in `issues_default_group_project`."
        ) from e


def cwe_role(name, rawtext, text, lineno, inliner, options=None, content=None):
    """Sphinx role for linking to a CWE on https://cwe.mitre.org.

    Examples: ::

        :cwe:`CWE-787`

    """
    options = options or {}
    content = content or []
    has_explicit_title, title, target = split_explicit_title(text)

    target = utils.unescape(target).strip()
    title = utils.unescape(title).strip()
    number = target[4:]
    ref = f"https://cwe.mitre.org/data/definitions/{number}.html"
    text = title if has_explicit_title else target
    link = nodes.reference(text=text, refuri=ref, **options)
    return [link], []


class IssueRole:
    # Symbols used to separate and issue/pull request/merge request etc
    # i.e
    #   - group/project#2323 for issues
    #   - group/project!1234 for merge requests (in gitlab)
    #   - group/project@adbc1234 for commits
    ELEMENT_SEPARATORS = "#@!"

    EXTERNAL_REPO_REGEX = re.compile(rf"^(\w+)/(.+)([{ELEMENT_SEPARATORS}])([\w]+)$")

    def __init__(
        self,
        config_prefix: str,
        pre_format_text: Callable[[Config, str], str] = None,
    ):
        self.uri_config = f"{config_prefix}_uri"
        self.separator_config = f"{config_prefix}_prefix"
        self.pre_format_text = pre_format_text or self.default_pre_format_text

    @staticmethod
    def default_pre_format_text(config: Config, text: str) -> str:
        return text

    def format_text(self, config: Config, issue_no: str) -> str:
        """
        Add supported separator in front of the issue or raise an error if invalid
        separator is given
        """
        separator = getattr(config, self.separator_config)
        if separator not in self.ELEMENT_SEPARATORS:
            raise ValueError(
                f"Option {self.separator_config} has to be one of "
                f"{', '.join(self.ELEMENT_SEPARATORS)}."
            )
        text = self.pre_format_text(config, issue_no.lstrip(self.ELEMENT_SEPARATORS))
        return f"{separator}{text}"

    def make_node(self, name: str, issue_no: str, config: Config, options=None):
        if issue_no in ("-", "0"):
            return None

        options = options or {}

        has_explicit_title, title, target = split_explicit_title(issue_no)

        if has_explicit_title:
            issue_no = str(target)

        repo_match = self.EXTERNAL_REPO_REGEX.match(issue_no)

        if repo_match:
            # External repo
            group, project, original_separator, issue_no = repo_match.groups()
            text = f"{group}/{project}{self.format_text(config, issue_no)}"
            ref = _get_uri(
                self.uri_config,
                config,
                issue_no,
                (group, project),
            )
        else:
            text = self.format_text(config, issue_no)
            ref = _get_uri(self.uri_config, config, issue_no)
        if has_explicit_title:
            return nodes.reference(text=title, refuri=ref, **options)
        else:
            return nodes.reference(text=text, refuri=ref, **options)

    def __call__(
        self, name, rawtext, text, lineno, inliner, options=None, content=None
    ):
        options = options or {}
        content = content or []
        issue_nos = [each.strip() for each in utils.unescape(text).split(",")]
        config = inliner.document.settings.env.app.config
        ret = []
        for i, issue_no in enumerate(issue_nos):
            node = self.make_node(name, issue_no, config, options=options)
            ret.append(node)
            if i != len(issue_nos) - 1:
                sep = nodes.raw(text=", ", format="html")
                ret.append(sep)
        return ret, []


"""Sphinx role for linking to an issue. Must have
`issues_uri` or `issues_default_group_project` configured in ``conf.py``.
Examples: ::
    :issue:`123`
    :issue:`42,45`
    :issue:`sloria/konch#123`
"""
issue_role = IssueRole(
    config_prefix="issues",
)

"""Sphinx role for linking to a pull request. Must have
`issues_pr_uri` or `issues_default_group_project` configured in ``conf.py``.
Examples: ::
    :pr:`123`
    :pr:`42,45`
    :pr:`sloria/konch#43`
"""
pr_role = IssueRole(
    config_prefix="issues_pr",
)


def format_commit_text(config, sha):
    return sha[:7]


"""Sphinx role for linking to a commit. Must have
`issues_commit_uri` or `issues_default_group_project` configured in ``conf.py``.
Examples: ::
    :commit:`123abc456def`
    :commit:`sloria/konch@123abc456def`
"""
commit_role = IssueRole(
    config_prefix="issues_commit",
    pre_format_text=format_commit_text,
)

"""Sphinx role for linking to a user profile. Defaults to linking to
GitHub profiles, but the profile URIS can be configured via the
``issues_user_uri`` config value.

Examples: ::

    :user:`sloria`

Anchor text also works: ::

    :user:`Steven Loria <sloria>`
"""
user_role = IssueRole(config_prefix="issues_user")


def setup(app):
    # Format template for issues URI
    # e.g. 'https://github.com/sloria/marshmallow/issues/{issue}
    app.add_config_value(
        "issues_uri",
        default="https://github.com/{group}/{project}/issues/{issue}",
        rebuild="html",
        types=[str],
    )
    app.add_config_value("issues_prefix", default="#", rebuild="html", types=[str])
    # Format template for PR URI
    # e.g. 'https://github.com/sloria/marshmallow/pull/{issue}
    app.add_config_value(
        "issues_pr_uri",
        default="https://github.com/{group}/{project}/pull/{pr}",
        rebuild="html",
        types=[str],
    )
    app.add_config_value("issues_pr_prefix", default="#", rebuild="html", types=[str])
    # Format template for commit URI
    # e.g. 'https://github.com/sloria/marshmallow/commits/{commit}
    app.add_config_value(
        "issues_commit_uri",
        default="https://github.com/{group}/{project}/commit/{commit}",
        rebuild="html",
        types=[str],
    )
    app.add_config_value(
        "issues_commit_prefix", default="@", rebuild="html", types=[str]
    )
    # There is no seperator config as a format_text function is given

    # Default User (Group)/Project eg. 'sloria/marshmallow'
    # Called github as the package was working with github only before
    app.add_config_value(
        "issues_github_path", default=None, rebuild="html", types=[str]
    )
    # Same as above but with new naming to reflect the new functionality
    # Only on of both can be set
    app.add_config_value(
        "issues_default_group_project", default=None, rebuild="html", types=[str]
    )
    # Format template for user profile URI
    # e.g. 'https://github.com/{user}'
    app.add_config_value(
        "issues_user_uri",
        default="https://github.com/{user}",
        rebuild="html",
        types=[str],
    )
    app.add_config_value("issues_user_prefix", default="@", rebuild="html", types=[str])
    app.add_role("issue", issue_role)
    app.add_role("pr", pr_role)
    app.add_role("user", user_role)
    app.add_role("commit", commit_role)
    app.add_role("cve", cve_role)
    app.add_role("cwe", cwe_role)
    return {
        "version": __version__,
        "parallel_read_safe": True,
        "parallel_write_safe": True,
    }
