from __future__ import annotations

import fnmatch
import os
import os.path
from pathlib import Path
from typing import TYPE_CHECKING

from breathe.exception import BreatheError

if TYPE_CHECKING:
    from collections import UserDict

    from sphinx.application import Sphinx

    class ProjectOptions(UserDict):
        path: str
        project: str


class ProjectError(BreatheError):
    pass


class NoDefaultProjectError(ProjectError):
    pass


class AutoProjectInfo:
    """Created as a temporary step in the automatic xml generation process"""

    def __init__(self, app: Sphinx, name: str, source_path: str, build_dir: str, reference: str):
        self.app = app

        self._name = name
        self._source_path = source_path
        self._build_dir = build_dir
        self._reference = reference

    def name(self):
        return self._name

    def build_dir(self):
        return self._build_dir

    def abs_path_to_source_file(self, file_):
        """
        Returns full path to the provide file assuming that the provided path is relative to the
        projects conf.py directory as specified in the breathe_projects_source config variable.
        """

        return Path(self.app.confdir, self._source_path, file_).resolve()

    def create_project_info(self, project_path):
        """Creates a proper ProjectInfo object based on the information in this AutoProjectInfo"""

        return ProjectInfo(self.app, self._name, project_path, self._source_path, self._reference)


class ProjectInfo:
    def __init__(self, app: Sphinx, name: str, path: str, source_path: str, reference: str):
        self.app = app

        self._name = name
        self._project_path = path
        self._source_path = source_path
        self._reference = reference

    def name(self) -> str:
        return self._name

    def project_path(self):
        return self._project_path

    def source_path(self):
        return self._source_path

    def relative_path_to_xml_file(self, file_):
        """
        Returns relative path from Sphinx documentation top-level source directory to the specified
        file assuming that the specified file is a path relative to the doxygen xml output
        directory.
        """

        # os.path.join does the appropriate handling if _project_path is an absolute path
        full_xml_project_path = os.path.join(self.app.confdir, self._project_path, file_)
        return os.path.relpath(full_xml_project_path, self.app.srcdir)

    def sphinx_abs_path_to_file(self, file_):
        """
        Prepends os.path.sep to the value returned by relative_path_to_file.

        This is to match Sphinx's concept of an absolute path which starts from the top-level source
        directory of the project.
        """
        return os.path.sep + self.relative_path_to_xml_file(file_)

    def reference(self):
        return self._reference

    def domain_for_file(self, file_: str) -> str:
        extension = file_.split(".")[-1]
        try:
            domain = self.app.config.breathe_domain_by_extension[extension]
        except KeyError:
            domain = ""

        domainFromFilePattern = self.app.config.breathe_domain_by_file_pattern
        for pattern, pattern_domain in domainFromFilePattern.items():
            if fnmatch.fnmatch(file_, pattern):
                domain = pattern_domain

        return domain


class ProjectInfoFactory:
    _default_build_dir: str

    def __init__(self, app: Sphinx):
        self.app = app
        # note: don't access self.app.config now, as we are instantiated at setup-time.

        # Assume general build directory is the parent of the doctree directory.
        # This can be overridden with the breathe_build_directory config variable
        self._default_build_dir = os.path.dirname(os.path.normpath(app.doctreedir))
        self.project_count = 0
        self.project_info_store: dict[str, ProjectInfo] = {}
        self.project_info_for_auto_store: dict[str, ProjectInfo] = {}
        self.auto_project_info_store: dict[str, AutoProjectInfo] = {}

    @property
    def build_dir(self) -> str:
        config = self.app.config
        if config.breathe_build_directory:
            return config.breathe_build_directory
        else:
            return self._default_build_dir

    def default_path(self) -> str:
        config = self.app.config
        if not config.breathe_default_project:
            raise NoDefaultProjectError(
                "No breathe_default_project config setting to fall back on "
                "for directive with no 'project' or 'path' specified."
            )

        try:
            return config.breathe_projects[config.breathe_default_project]
        except KeyError:
            raise ProjectError(
                (
                    "breathe_default_project value '%s' does not seem to be a valid key for the "
                    "breathe_projects dictionary"
                )
                % config.breathe_default_project
            )

    def create_project_info(self, options: ProjectOptions) -> ProjectInfo:
        config = self.app.config
        name = config.breathe_default_project

        if "project" in options:
            try:
                path = config.breathe_projects[options["project"]]
                name = options["project"]
            except KeyError:
                raise ProjectError(
                    "Unable to find project '%s' in breathe_projects dictionary"
                    % options["project"]
                )
        elif "path" in options:
            path = options["path"]
        else:
            path = self.default_path()

        try:
            return self.project_info_store[path]
        except KeyError:
            reference = name
            if not name:
                name = "project%s" % self.project_count
                reference = path
                self.project_count += 1

            project_info = ProjectInfo(self.app, name, path, "NoSourcePath", reference)
            self.project_info_store[path] = project_info
            return project_info

    def store_project_info_for_auto(self, name: str, project_info: ProjectInfo) -> None:
        """Stores the project info by name for later extraction by the auto directives.

        Stored separately to the non-auto project info objects as they should never overlap.
        """

        self.project_info_for_auto_store[name] = project_info

    def retrieve_project_info_for_auto(self, options) -> ProjectInfo:
        """Retrieves the project info by name for later extraction by the auto directives.

        Looks for the 'project' entry in the options dictionary. This is a less than ideal API but
        it is designed to match the use of 'create_project_info' above for which it makes much more
        sense.
        """

        name = options.get("project", self.app.config.breathe_default_project)
        if name is None:
            raise NoDefaultProjectError(
                "No breathe_default_project config setting to fall back on "
                "for directive with no 'project' or 'path' specified."
            )
        return self.project_info_for_auto_store[name]

    def create_auto_project_info(self, name: str, source_path) -> AutoProjectInfo:
        key = source_path
        try:
            return self.auto_project_info_store[key]
        except KeyError:
            reference = name
            if not name:
                name = "project%s" % self.project_count
                reference = source_path
                self.project_count += 1

            auto_project_info = AutoProjectInfo(
                self.app, name, source_path, self.build_dir, reference
            )
            self.auto_project_info_store[key] = auto_project_info
            return auto_project_info
