import json
import logging
import re
import subprocess
import tempfile
import uuid
from pathlib import Path
from typing import Dict, List, Optional, Pattern

logger = logging.getLogger(__name__)


def _tidy_doc(doc: str) -> str:
    doc = doc.strip()
    doc = re.sub(r":.+?:`(.+?)`", r"\1", doc)
    doc = re.sub(r"``([^`]+)``", r"`\1`", doc)
    doc = doc.replace("\n", " ")
    doc = doc.replace(".  ", ". ")
    return doc


class API(object):
    _cmake: str
    _build: Path
    _uuid: uuid.UUID
    _builtin_commands: Dict[str, str]
    _builtin_variables: Dict[str, str]
    _builtin_variable_template: Dict[Pattern[str], str]
    _builtin_modules: Dict[str, str]
    _targets: List[str]
    _cached_variables: Dict[str, str]
    _generated_list_parsed: bool

    def __init__(self, cmake: str, build: Path):
        self._cmake = cmake
        self._build = Path(build)
        self._uuid = uuid.uuid4()

        self._builtin_commands = {}
        self._builtin_variables = {}
        self._builtin_variable_template = {}
        self._builtin_modules = {}
        self._targets = []
        self._cached_variables = {}
        self._generated_list_parsed = False

    def query(self) -> bool:
        """Use CMake's file API to get JSON information about the build tree

        Generates a JSON request file for the current build tree and runs
        CMake on the build tree. Deletes the request file immediately
        after.
        """
        if not self.cmake_cache.exists():
            return False

        self.query_json.parent.mkdir(parents=True, exist_ok=True)
        with self.query_json.open("w") as fp:
            fp.write(
                """\
{
  "requests": [
    {"kind": "codemodel", "version": 2},
    {"kind": "cache", "version": 2},
    {"kind": "cmakeFiles", "version": 1}
  ]
}"""
            )

        proc = subprocess.run(
            [self._cmake, str(self._build)],
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            encoding="utf-8",
            universal_newlines=True,
        )
        self.query_json.unlink()
        self.query_json.parent.rmdir()
        if proc.returncode != 0:
            logging.error(f"cmake exited with {proc.returncode}: {proc.stderr}")
            return False

        return True

    def read_reply(self) -> bool:
        """Reads the CMake file API reply file and updates internal state

        Reads the result of the previous query file and updates
        the targets, the cache entries and the cmake files.
        """
        reply = self._build / ".cmake" / "api" / "v1" / "reply"
        indices = sorted(reply.glob("index-*.json"))
        if not indices:
            logger.error("no reply")
            return False
        with indices[-1].open() as fp:
            index = json.load(fp)
        try:
            responses = index["reply"][f"client-{self._uuid}"]["query.json"][
                "responses"
            ]
        except KeyError:
            logger.error("no rensponse")
            return False
        for response in responses:
            if response["kind"] == "codemodel":
                self._read_codemodel(reply / response["jsonFile"])
            elif response["kind"] == "cache":
                self._read_cache(reply / response["jsonFile"])
            elif response["kind"] == "cmakeFiles":
                self._read_cmake_files(reply / response["jsonFile"])

        return True

    def _read_codemodel(self, codemodelpath: Path) -> None:
        with (codemodelpath).open() as fp:
            codemodel = json.load(fp)
        config = codemodel["configurations"][0]
        self._targets[:] = [x["name"] for x in config["targets"]]

    def _read_cache(self, cachepath: Path) -> None:
        with cachepath.open() as fp:
            cache = json.load(fp)
        self._cached_variables.clear()
        for entry in cache["entries"]:
            name = entry["name"]
            value = self._truncate_variable(entry["value"])
            properties = {x["name"]: x["value"] for x in entry["properties"]}
            helpstring = properties.get("HELPSTRING", "")
            doc = []
            if helpstring:
                doc.append(helpstring)
            if value:
                doc.append(f"`{value}`")
            self._cached_variables[name] = "\n\n".join(doc)

    def _read_cmake_files(self, jsonpath: Path) -> None:
        """inspect CMake list files that are used during build generation"""

        if not self._builtin_variables or self._generated_list_parsed:
            return

        with jsonpath.open() as fp:
            cmake_files = json.load(fp)

        # Inspect generated list files: Get the values of variables in each script
        with tempfile.TemporaryDirectory() as tmpdirname:
            tmplist = Path(tmpdirname) / "dump.cmake"
            with tmplist.open("w") as fp:
                for listfile in cmake_files["inputs"]:
                    if not listfile.get("isGenerated", False):
                        continue
                    path = listfile["path"]
                    fp.write(f"include({path})\n")
                fp.write(
                    """
get_cmake_property(variables VARIABLES)
foreach (variable ${variables})
  message("${variable}=${${variable}}")
endforeach()
"""
                )
            p = subprocess.run(
                [self._cmake, "-P", str(tmplist)],
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                cwd=cmake_files["paths"]["source"],
                encoding="utf-8",
                universal_newlines=True,
            )
            if p.returncode != 0:
                return

            for line in p.stderr.split("\n"):
                line = line.strip()
                if not line:
                    continue
                k, v = line.split("=", 1)
                if k.startswith("CMAKE_ARG"):
                    continue
                v = self._truncate_variable(v)
                if k in self._builtin_variables:
                    self._builtin_variables[k] += f"\n\n`{v}`"
                else:
                    for pattern, doc in self._builtin_variable_template.items():
                        if pattern.fullmatch(k):
                            self._builtin_variables[k] = f"{doc}\n\n`{v}`"
                            break
                    else:
                        # ignore variable with no document
                        pass

        self._generated_list_parsed = True

    @property
    def query_json(self) -> Path:
        return (
            self._build
            / ".cmake"
            / "api"
            / "v1"
            / "query"
            / f"client-{self._uuid}"
            / "query.json"
        )

    @property
    def cmake_cache(self) -> Path:
        return self._build / "CMakeCache.txt"

    def parse_doc(self) -> None:
        self._parse_commands()
        self._parse_variables()
        self._parse_modules()

    def _parse_commands(self) -> None:
        """Load docs for builtin cmake functions

        Loads the documentation for builtin cmake functions from the result
        of `$ cmake --help-commands`.
        """
        p = subprocess.run(
            [self._cmake, "--help-commands"],
            stdout=subprocess.PIPE,
            encoding="utf-8",
            universal_newlines=True,
        )

        if p.returncode != 0:
            return

        matches = re.finditer(
            r"""
(?P<command>.+)\n
-+\n+?
[\s\S]*?
(?P<signature>(?P=command)\s*\([^)]*\))
""",
            p.stdout,
            re.VERBOSE,
        )
        self._builtin_commands.clear()
        for match in matches:
            command = match.group("command")
            signature = match.group("signature")
            signature = re.sub(r"^ ", r"", signature, flags=re.MULTILINE)
            self._builtin_commands[command] = "```cmake\n" + signature + "\n```"

    def _parse_variables(self) -> None:
        """Load docs for builtin cmake variables

        Loads the documentation for builtin cmake variables from
        the result of `$ cmake --help-variables`.
        """
        p = subprocess.run(
            [self._cmake, "--help-variables"],
            stdout=subprocess.PIPE,
            encoding="utf-8",
            universal_newlines=True,
        )

        if p.returncode != 0:
            return

        matches = re.finditer(
            r"""
(?P<variable>.+)\n
-+\n\n
(?P<doc>[\s\S]+?)(?:\n\n|$)
""",
            p.stdout,
            re.VERBOSE,
        )
        self._builtin_variables.clear()
        for match in matches:
            variable = match.group("variable")
            doc = _tidy_doc(match.group("doc"))
            if variable == "CMAKE_MATCH_<n>":
                for i in range(10):
                    self._builtin_variables[f"CMAKE_MATCH_{i}"] = doc
            elif "<" in variable:
                variable = re.sub(r"<[^>]+>", r"[^_]+", variable)
                pattern = re.compile(variable)
                self._builtin_variable_template[pattern] = doc
            else:
                self._builtin_variables[variable] = doc

    def _parse_modules(self) -> None:
        """Loads docs for all modules in the cmake distribution

        Loads the documentation for cmake modules included in the
        distribution from the result of `$ cmake --help-modules`.
        """
        p = subprocess.run(
            [self._cmake, "--help-modules"],
            stdout=subprocess.PIPE,
            encoding="utf-8",
            universal_newlines=True,
        )

        if p.returncode != 0:
            return

        matches = re.finditer(
            r"""
(?P<module>.+)\n
-+\n+?
(?:(?P<header>\w[\w\s]+)\n\^+\n+?)?
(?P<doc>(?:.|\n)*?\n\n)
""",
            p.stdout + "\n\n",
            re.VERBOSE,
        )
        self._builtin_modules.clear()
        for match in matches:
            module = match.group("module")
            header = match.group("header")
            doc = _tidy_doc(match.group("doc"))
            if header != "Overview":
                doc = ""
            self._builtin_modules[module] = doc

    def get_command_doc(self, command: str) -> Optional[str]:
        return self._builtin_commands.get(command)

    def search_command(self, command: str) -> List[str]:
        command = command.lower()
        return [x for x in self._builtin_commands if x.startswith(command)]

    def get_variable_doc(self, variable: str) -> Optional[str]:
        doc = self._cached_variables.get(variable)
        if doc:
            return doc
        return self._builtin_variables.get(variable)

    def search_variable(self, variable: str) -> List[str]:
        cached = frozenset(x for x in self._cached_variables if x.startswith(variable))
        builtin = frozenset(
            x for x in self._builtin_variables if x.startswith(variable)
        )
        return list(cached | builtin)

    def get_module_doc(self, module: str, package: bool) -> Optional[str]:
        if package:
            return self._builtin_modules.get("Find" + module)

        return self._builtin_modules.get(module)

    def search_module(self, module: str, package: bool) -> List[str]:
        if package:
            module = "Find" + module
            return [x[4:] for x in self._builtin_modules if x.startswith(module)]

        return [
            x
            for x in self._builtin_modules
            if x.startswith(module) and not x.startswith("Find")
        ]

    def search_target(self, target: str) -> List[str]:
        return [x for x in self._targets if x.startswith(target)]

    def _truncate_variable(self, v: str) -> str:
        width = 70
        return v[:width] + (v[width:] and "...")
