#
# Copyright (c) 2019 James E. King III
#
# Use, modification, and distribution are subject to the
# Boost Software License, Version 1.0. (See accompanying file
# LICENSE_1_0.txt or copy at https://www.boost.org/LICENSE_1_0.txt)
#

import json
import networkx
import re
from pathlib import Path


class BoostDependencyTree(object):
    """
    Generates a PlantUML dependency tree to visualize the dependencies.

    One of the benefits of generating a visual graph is that cycles become
    immediately evident.
    """
    EDGES = {
        2: "-->",
        1: "..>"
    }

    STRENGTHS = {
        "include": 2,
        "src": 2,
        "test": 1,
        "tests": 1
    }

    def __init__(self, root: Path, out: Path):
        """
        Arguments:
          root: path to BOOST_ROOT
          out: path to output file
        """
        self.exp = re.compile(r"^\s*#\s*include\s*[<\"](?P<header>[^>\"]+)[>\"]\s*$")

        self.graph = networkx.DiGraph()
        self.headers = {}  # key: header include path; value: repo key
        self.repos = {}  # key: repo key; value: repo path

        self.out = out
        self.root = root
        self.libs = self.root / "libs"

        with (self.libs / "config" / "include" / "boost" / "version.hpp").open() as fp:
            vlines = fp.readlines()
            for vline in vlines:
                if "BOOST_LIB_VERSION" in vline:
                    #define BOOST_LIB_VERSION "1_71"
                    tokens = vline.split(" ")
                    self.boost_version = tokens[2].strip()[1:-1].replace("_", ".")

    def load(self):
        self.collect()
        self.analyze()

    def collect(self):
        """
        Locate every .hpp and .h file and associate it with a repository.
        """
        metas = self.libs.glob("**/libraries.json")
        for meta in metas:
            with meta.open() as fp:
                metadata = json.loads(fp.read())
            repodir = meta.parent.parent
            metadata = metadata[0] if isinstance(metadata, list) else metadata  # for boost/core
            repokey = metadata["key"]
            repoinc = repodir / "include"
            if repoinc.is_dir():  # libs/geometry/index has no include but looks like a repo?
                self.graph.add_node(repokey)
                self.repos[repokey] = repodir
                headers = repoinc.glob("**/*.h??")
                for header in headers:
                    # print(str(header))
                    incpath = header.relative_to(repoinc)
                    assert incpath not in self.headers,\
                        f"{incpath} in {repokey} already in header map from "\
                        f"{self.headers[incpath]} - duplicate header paths!"
                    self.headers[str(incpath)] = repokey

    def analyze(self):
        """
        Find every include statement and create a graph of dependencies.
        """
        for repokey, repodir in self.repos.items():
            for ext in ["c", "cpp", "h", "hpp", "ipp"]:
                files = repodir.glob("**/*." + ext)
                for code in files:
                    inside = code.relative_to(repodir).parts[0]
                    if inside not in self.STRENGTHS.keys():
                        continue
                    weight = self.STRENGTHS[inside]
                    with code.open() as fp:
                        try:
                            #print(str(code))
                            source = fp.readlines()
                        except UnicodeDecodeError:
                            continue
                        for line in source:
                            match = self.exp.search(line)
                            if match:
                                include = match.group("header")
                                if include in self.headers:
                                    deprepo = self.headers[include]
                                    if repokey != deprepo:  # avoid self-references
                                        data = self.graph.get_edge_data(repokey, deprepo, {"weight": 0})
                                        if data["weight"] > 0 and data["weight"] < weight:
                                            self.graph.remove_edge(repokey, deprepo)
                                            data["weight"] = 0
                                        if data["weight"] == 0:
                                            self.graph.add_edge(repokey, deprepo, weight=weight)

    def report_cycles(self):
        with self.out.open("w") as fp:
            fp.write("@startuml\n")
            fp.write("\n")
            fp.write(f"title Boost {self.boost_version} Direct Dependency Cycles\n")
            fp.write("footer Generated by boost-deptree (C) 2019 James E. King III\n")
            fp.write("\n")
            for edge in self.graph.edges:
                fwdweight = self.graph.get_edge_data(edge[0], edge[1])["weight"]
                if fwdweight > 1:
                    if self.graph.get_edge_data(edge[1], edge[0], {"weight": 0})["weight"] > 1:
                        fp.write(f"['{edge[0]}'] --> ['{edge[1]}']\n")
            fp.write("\n")
            fp.write("@enduml\n")

    def report_dependencies_from(self, repokey):
        with self.out.open("w") as fp:
            fp.write("@startuml\n")
            fp.write("\n")
            fp.write(f"title Boost {self.boost_version} dependencies of {repokey}\n")
            fp.write("footer Generated by boost-deptree (C) 2019 James E. King III\n")
            fp.write("\n")
            for edge in self.graph.edges:
                if edge[0] == repokey:
                    fwdweight = self.graph.get_edge_data(edge[0], edge[1])["weight"]
                    fp.write(f"['{edge[0]}'] {self.EDGES[fwdweight]} ['{edge[1]}']\n")
            fp.write("\n")
            fp.write("@enduml\n")


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description='Generate PlantUML dependency tree.')
    parser.add_argument('root', type=str, help='Boost root directory.')
    parser.add_argument('out', type=str, help='Output filename.')
    require_one = parser.add_mutually_exclusive_group(required=True)
    require_one.add_argument('--cycles', action='store_true', help='Show direct repository dependency cycles.')
    require_one.add_argument('--from', help='Show dependencies from a given repository.')
    args = parser.parse_args()
    root = Path(args.root)
    assert root.is_dir(), "root is not a directory"
    out = Path(args.out)
    tree = BoostDependencyTree(root, out)
    tree.load()
    if args.cycles:
        tree.report_cycles()
    else:
        tree.report_dependencies_from(args.__dict__["from"])

[((150, 13, 150, 86), 'argparse.ArgumentParser', 'argparse.ArgumentParser', (), '', False, 'import argparse\n'), ((157, 11, 157, 26), 'pathlib.Path', 'Path', ({(157, 16, 157, 25): 'args.root'}, {}), '(args.root)', False, 'from pathlib import Path\n'), ((159, 10, 159, 24), 'pathlib.Path', 'Path', ({(159, 15, 159, 23): 'args.out'}, {}), '(args.out)', False, 'from pathlib import Path\n'), ((40, 19, 40, 85), 're.compile', 're.compile', ({(40, 30, 40, 84): '"""^\\\\s*#\\\\s*include\\\\s*[<\\\\"](?P<header>[^>\\\\"]+)[>\\\\"]\\\\s*$"""'}, {}), '(\'^\\\\s*#\\\\s*include\\\\s*[<\\\\"](?P<header>[^>\\\\"]+)[>\\\\"]\\\\s*$\')', False, 'import re\n'), ((42, 21, 42, 39), 'networkx.DiGraph', 'networkx.DiGraph', ({}, {}), '()', False, 'import networkx\n')]