import builtins
import glob
import os.path
import re
import sys
import sysconfig
from functools import lru_cache

DEFAULT_INCLUDE_DIR = sysconfig.get_config_var("INCLUDEDIR")
DEFAULT_LIB_DIR = sysconfig.get_config_var("LIBDIR")
BUILD_TYPES = (
    "debug",
    "default",
    "optimized",
    "release",
    "relwithdebinfo",
    "minsizerel",
)


def find_ns3_lock() -> str:
    # Get the absolute path to this file
    path_to_this_init_file = os.path.dirname(os.path.abspath(__file__))
    path_to_lock = path_to_this_init_file
    lock_file = ".lock-ns3_%s_build" % sys.platform

    # Move upwards until we reach the directory with the ns3 script
    prev_path = None
    while "ns3" not in os.listdir(path_to_lock):
        prev_path = path_to_lock
        path_to_lock = os.path.dirname(path_to_lock)
        if prev_path == path_to_lock:
            break

    # We should be now at the directory that contains a lock if the project is configured
    if lock_file in os.listdir(path_to_lock):
        path_to_lock += os.sep + lock_file
    else:
        path_to_lock = ""
    return path_to_lock


SYSTEM_LIBRARY_DIRECTORIES = (
    DEFAULT_LIB_DIR,
    os.path.dirname(DEFAULT_LIB_DIR),
    "/usr/lib64",
    "/usr/lib",
)
DYNAMIC_LIBRARY_EXTENSIONS = {
    "linux": "so",
    "win32": "dll",
    "darwin": "dylib",
}
LIBRARY_EXTENSION = DYNAMIC_LIBRARY_EXTENSIONS[sys.platform]


def trim_library_path(library_path: str) -> str:
    trimmed_library_path = os.path.basename(library_path)

    # Remove lib prefix if it exists and extensions
    trimmed_library_path = trimmed_library_path.split(".")[0]
    if trimmed_library_path[0:3] == "lib":
        trimmed_library_path = trimmed_library_path[3:]
    return trimmed_library_path


@lru_cache(maxsize=None)
def _search_libraries() -> dict:
    # Otherwise, search for ns-3 libraries
    # Should be the case when ns-3 is installed as a package
    env_sep = ";" if sys.platform == "win32" else ":"

    # Search in default directories PATH and LD_LIBRARY_PATH
    library_search_paths = os.getenv("PATH", "").split(env_sep)
    library_search_paths += os.getenv("LD_LIBRARY_PATH", "").split(env_sep)
    if "" in library_search_paths:
        library_search_paths.remove("")
    del env_sep

    # And the current working directory too
    library_search_paths += [os.path.abspath(os.getcwd())]

    # And finally the directories containing this file and its parent directory
    library_search_paths += [os.path.abspath(os.path.dirname(__file__))]
    library_search_paths += [os.path.dirname(library_search_paths[-1])]
    library_search_paths += [os.path.dirname(library_search_paths[-1])]

    # Filter unique search paths and those that are not part of system directories
    library_search_paths = list(
        filter(lambda x: x not in SYSTEM_LIBRARY_DIRECTORIES, set(library_search_paths))
    )

    # Exclude injected windows paths in case of WSL
    # BTW, why Microsoft? Who had this brilliant idea?
    library_search_paths = list(filter(lambda x: "/mnt/c/" not in x, library_search_paths))

    # Search for the core library in the search paths
    libraries = []
    for search_path in library_search_paths:
        if os.path.exists(search_path):
            libraries += glob.glob(
                "%s/**/*.%s*" % (search_path, LIBRARY_EXTENSION),
                recursive=not os.path.exists(os.path.join(search_path, "ns3")),
            )

    # Search system library directories (too slow for recursive search)
    for search_path in SYSTEM_LIBRARY_DIRECTORIES:
        if os.path.exists(search_path):
            libraries += glob.glob(
                "%s/**/*.%s*" % (search_path, LIBRARY_EXTENSION), recursive=False
            )
            libraries += glob.glob("%s/*.%s*" % (search_path, LIBRARY_EXTENSION), recursive=False)

    del search_path, library_search_paths

    library_map = {}
    # Organize libraries into a map
    for library in libraries:
        library_infix = trim_library_path(library)

        # Then check if a key already exists
        if library_infix not in library_map:
            library_map[library_infix] = set()

        # Append the directory
        library_map[library_infix].add(os.path.realpath(library))

    # Replace sets with lists
    for key, values in library_map.items():
        library_map[key] = list(values)
    return library_map


def search_libraries(library_name: str) -> list:
    libraries_map = _search_libraries()
    trimmed_library_name = trim_library_path(library_name)
    matched_names = list(filter(lambda x: trimmed_library_name in x, libraries_map.keys()))
    matched_libraries = []

    if matched_names:
        for match in matched_names:
            matched_libraries += libraries_map[match]
    return matched_libraries


LIBRARY_AND_DEFINES = {
    "libgsl": ["HAVE_GSL"],
    "libxml2": ["HAVE_LIBXML2"],
    "libsqlite3": ["HAVE_SQLITE3"],
    "openflow": ["NS3_OPENFLOW", "ENABLE_OPENFLOW"],
    "click": ["NS3_CLICK"],
}


def add_library_defines(library_name: str):
    has_defines = list(filter(lambda x: x in library_name, LIBRARY_AND_DEFINES.keys()))
    defines = ""
    if len(has_defines):
        for define in LIBRARY_AND_DEFINES[has_defines[0]]:
            defines += f"""
                    #ifndef {define}
                    #define {define} 1
                    #endif
                """
    return defines


def extract_linked_libraries(library_name: str, prefix: str) -> tuple:
    lib = ""
    for variant in ["lib", "lib64"]:
        library_path = f"{prefix}/{variant}/{library_name}"
        if os.path.exists(library_path):
            lib = variant
            break
    linked_libs = []
    # First discover which 3rd-party libraries are used by the current module
    try:
        with open(os.path.abspath(library_path), "rb") as f:
            linked_libs = re.findall(
                b"\x00(lib[^\\x00]*?\\.%b)(?![\\w.])" % LIBRARY_EXTENSION.encode("utf-8"), f.read()
            )
    except Exception as e:
        print(f"Failed to extract libraries used by {library_path} with exception:{e}")
        exit(-1)
    return library_path, lib, list(map(lambda x: x.decode("utf-8"), linked_libs))


def extract_library_include_dirs(library_name: str, prefix: str) -> tuple:
    library_path, lib, linked_libs = extract_linked_libraries(library_name, prefix)

    linked_libs_include_dirs = set()
    defines = add_library_defines(library_name)

    # Now find these libraries and add a few include paths for them
    for linked_library in linked_libs:
        # Skip ns-3 modules
        if "libns3" in linked_library:
            continue

        # Search for the absolute path of the library
        linked_library_path = search_libraries(linked_library)

        # Raise error in case the library can't be found
        if len(linked_library_path) == 0:
            raise Exception(
                f"Failed to find {linked_library}. Make sure its library directory is in LD_LIBRARY_PATH."
            )

        # Get path with the shortest length
        linked_library_path = sorted(linked_library_path, key=lambda x: len(x))[0]

        # If library is part of the ns-3 build, continue without any new includes
        if prefix in linked_library_path:
            continue

        # Add defines based in linked libraries found
        defines += add_library_defines(linked_library)

        # If it is part of the system directories, try to find it
        system_include_dir = os.path.dirname(linked_library_path).replace(lib, "include")
        if os.path.exists(system_include_dir):
            linked_libs_include_dirs.add(system_include_dir)

            # If system_include_dir/library_name exists, we add it too
            linked_library_name = linked_library.replace("lib", "").replace(
                "." + LIBRARY_EXTENSION, ""
            )
            if os.path.exists(os.path.join(system_include_dir, linked_library_name)):
                linked_libs_include_dirs.add(os.path.join(system_include_dir, linked_library_name))

        # In case it isn't, include new include directories based on the path
        def add_parent_dir_recursively(x: str, y: int) -> None:
            if y <= 0:
                return
            parent_dir = os.path.dirname(x)
            linked_libs_include_dirs.add(parent_dir)
            add_parent_dir_recursively(parent_dir, y - 1)

        add_parent_dir_recursively(linked_library_path, 2)

        for lib_path in list(linked_libs_include_dirs):
            inc_path = os.path.join(lib_path, "include")
            if os.path.exists(inc_path):
                linked_libs_include_dirs.add(inc_path)
    return list(linked_libs_include_dirs), defines


def find_ns3_from_lock_file(lock_file: str) -> (str, list, str):
    # Load NS3_ENABLED_MODULES from the lock file inside the build directory
    values = {}

    # If we find a lock file, load the ns-3 modules from it
    # Should be the case when running from the source directory
    exec(open(lock_file).read(), {}, values)
    suffix = "-" + values["BUILD_PROFILE"] if values["BUILD_PROFILE"] != "release" else ""
    modules = list(
        map(
            lambda x: x.replace("ns3-", ""),
            values["NS3_ENABLED_MODULES"] + values["NS3_ENABLED_CONTRIBUTED_MODULES"],
        )
    )

    prefix = values["out_dir"]
    path_to_lib = None
    for variant in ["lib", "lib64"]:
        path_candidate = os.path.join(prefix, variant)
        if os.path.isdir(path_candidate):
            path_to_lib = path_candidate
            break
    if path_to_lib is None:
        raise Exception(f"Directory {prefix} does not contain subdirectory lib/ (nor lib64/).")
    libraries = {os.path.splitext(os.path.basename(x))[0]: x for x in os.listdir(path_to_lib)}

    version = values["VERSION"]

    # Filter out test libraries and incorrect versions
    def filter_in_matching_ns3_libraries(
        libraries_to_filter: dict,
        modules_to_filter: list,
        version: str,
        suffix: str,
    ) -> dict:
        suffix = [suffix[1:]] if len(suffix) > 1 else []
        filtered_in_modules = []
        for module in modules_to_filter:
            filtered_in_modules += list(
                filter(
                    lambda x: "-".join([version, module, *suffix]) in x, libraries_to_filter.keys()
                )
            )
        for library in list(libraries_to_filter.keys()):
            if library not in filtered_in_modules:
                libraries_to_filter.pop(library)
        return libraries_to_filter

    libraries = filter_in_matching_ns3_libraries(libraries, modules, version, suffix)

    # When we have the lock file, we assemble the correct library names
    libraries_to_load = []
    for module in modules:
        library_name = f"libns{version}-{module}{suffix}"
        if library_name not in libraries:
            raise Exception(
                f"Missing library {library_name}\n", "Build all modules with './ns3 build'"
            )
        libraries_to_load.append(libraries[library_name])
    return prefix, libraries_to_load, version


# Extract version and build suffix (if it exists)
def filter_module_name(library: str) -> str:
    library = os.path.splitext(os.path.basename(library))[0]
    components = library.split("-")

    # Remove version-related prefixes
    if "libns3" in components[0]:
        components.pop(0)
    if "dev" == components[0]:
        components.pop(0)
    if "rc" in components[0]:
        components.pop(0)

    # Drop build profile suffix and test libraries
    if components[-1] in BUILD_TYPES:
        components.pop(-1)
    return "-".join(components)


def extract_version(library: str, module: str) -> str:
    library = os.path.basename(library)
    return re.findall(r"libns([\d.|rc|\-dev]+)-", library)[0]


def get_newest_version(versions: list) -> str:
    versions = list(sorted(versions))
    if "dev" in versions[0]:
        return versions[0]

    # Check if there is a release of a possible candidate
    try:
        pos = versions.index(os.path.splitext(versions[-1])[0])
    except ValueError:
        pos = None

    # Remove release candidates
    if pos is not None:
        return versions[pos]
    else:
        return versions[-1]


def find_ns3_from_search() -> (str, list, str):
    libraries = search_libraries("ns3")

    # Filter in libraries prefixed with libns3
    libraries = list(filter(lambda x: "libns3" in x, libraries))

    if not libraries:
        raise Exception("ns-3 libraries were not found.")

    # If there is a version with a hash by the end, we have a pip-installed library
    pip_libraries = list(filter(lambda x: "python" in x, libraries))
    if pip_libraries:
        # We drop non-pip libraries
        libraries = pip_libraries

    # The prefix is the directory with the lib directory
    # libns3-dev-core.so/../../
    prefix = os.path.dirname(os.path.dirname(libraries[0]))

    # Remove test libraries
    libraries = list(filter(lambda x: "test" not in x, libraries))

    # Filter out module names
    modules = set([filter_module_name(library) for library in libraries])

    def filter_in_newest_ns3_libraries(libraries_to_filter: list, modules_to_filter: list) -> tuple:
        newest_version_found = ""
        # Filter out older ns-3 libraries
        for module in list(modules_to_filter):
            # Filter duplicates of modules, while excluding test libraries
            conflicting_libraries = list(
                filter(lambda x: module == filter_module_name(x), libraries_to_filter)
            )

            # Extract versions from conflicting libraries
            conflicting_libraries_versions = list(
                map(lambda x: extract_version(x, module), conflicting_libraries)
            )

            # Get the newest version found for that library
            newest_version = get_newest_version(conflicting_libraries_versions)

            # Check if the version found is the global newest version
            if not newest_version_found:
                newest_version_found = newest_version
            else:
                newest_version_found = get_newest_version([newest_version, newest_version_found])
                if newest_version != newest_version_found:
                    raise Exception(
                        f"Incompatible versions of the ns-3 module '{module}' were found: {newest_version} != {newest_version_found}."
                    )

            for conflicting_library in list(conflicting_libraries):
                if "-".join([newest_version, module]) not in conflicting_library:
                    libraries.remove(conflicting_library)
                    conflicting_libraries.remove(conflicting_library)

            if len(conflicting_libraries) > 1:
                raise Exception(
                    f"There are multiple build profiles for module '{module}'.\nDelete one to continue: {', '.join(conflicting_libraries)}"
                )

        return libraries_to_filter, newest_version_found

    # Get library base names
    libraries, version = filter_in_newest_ns3_libraries(libraries, list(modules))
    return prefix, libraries, version


def load_modules():
    lock_file = find_ns3_lock()
    libraries_to_load = []

    # Search for prefix to ns-3 build, modules and respective libraries plus version
    ret = find_ns3_from_search() if not lock_file else find_ns3_from_lock_file(lock_file)

    # Unpack returned values
    prefix, libraries, version = ret
    prefix = os.path.abspath(prefix)

    # Sort libraries according to their dependencies
    def sort_to_dependencies(libraries: list, prefix: str) -> list:
        module_dependencies = {}
        libraries = list(map(lambda x: os.path.basename(x), libraries))
        for ns3_library in libraries:
            _, _, linked_libraries = extract_linked_libraries(ns3_library, prefix)
            linked_libraries = list(
                filter(lambda x: "libns3" in x and ns3_library not in x, linked_libraries)
            )
            linked_libraries = list(map(lambda x: os.path.basename(x), linked_libraries))
            module_dependencies[os.path.basename(ns3_library)] = linked_libraries

        def modules_that_can_be_loaded(module_dependencies, pending_modules, current_modules):
            modules = []
            for pending_module in pending_modules:
                can_be_loaded = True
                for dependency in module_dependencies[pending_module]:
                    if dependency not in current_modules:
                        can_be_loaded = False
                        break
                if not can_be_loaded:
                    continue
                modules.append(pending_module)
            return modules

        def dependency_order(
            module_dependencies, pending_modules, current_modules, step_number=0, steps={}
        ):
            if len(pending_modules) == 0:
                return steps
            if step_number not in steps:
                steps[step_number] = []
            for module in modules_that_can_be_loaded(
                module_dependencies, pending_modules, current_modules
            ):
                steps[step_number].append(module)
                pending_modules.remove(module)
                current_modules.append(module)
            return dependency_order(
                module_dependencies, pending_modules, current_modules, step_number + 1, steps
            )

        sorted_libraries = []
        for step in dependency_order(
            module_dependencies, list(module_dependencies.keys()), [], 0
        ).values():
            sorted_libraries.extend(step)
        return sorted_libraries

    libraries_to_load = sort_to_dependencies(libraries, prefix)

    # Extract library base names
    libraries_to_load = [os.path.basename(x) for x in libraries_to_load]

    # Sort modules based on libraries
    modules = list(map(lambda x: filter_module_name(x), libraries_to_load))

    # Try to import Cppyy and warn the user in case it is not found
    try:
        import cppyy
    except ModuleNotFoundError:
        print("Cppyy is required by the ns-3 python bindings.")
        print("You can install it with the following command: pip install cppyy")
        exit(-1)

    # Enable full logs for debugging
    # cppyy.set_debug(True)

    # Register Ptr<> as a smart pointer
    import libcppyy

    libcppyy.AddSmartPtrType("Ptr")

    # Import ns-3 libraries
    for variant in ["lib", "lib64"]:
        path_to_lib = f"{prefix}/{variant}"
        if not os.path.exists(path_to_lib):
            continue
        cppyy.add_library_path(path_to_lib)
    del variant, path_to_lib
    cppyy.add_include_path(f"{prefix}/include")

    known_include_dirs = set()
    # We then need to include all include directories for dependencies
    for library in libraries_to_load:
        linked_lib_include_dirs, defines = extract_library_include_dirs(library, prefix)
        cppyy.cppexec(defines)
        for linked_lib_include_dir in linked_lib_include_dirs:
            if linked_lib_include_dir not in known_include_dirs:
                known_include_dirs.add(linked_lib_include_dir)
                if os.path.isdir(linked_lib_include_dir):
                    cppyy.add_include_path(linked_lib_include_dir)

    # Get build type
    build_type = ""  # release
    for type in BUILD_TYPES:
        if type in libraries_to_load[-1]:
            build_type = type

    # Load a module, then its module header
    for library in libraries_to_load:
        cppyy.load_library(library)
        for module in modules:
            library_name_from_module = (
                f"{version}-{module}{'-' if len(build_type)>0 else ''}{build_type}."
            )
            if library_name_from_module in library:
                cppyy.include(f"ns3/{module}-module.h")
                break

    # We expose cppyy to consumers of this module as ns.cppyy
    setattr(cppyy.gbl.ns3, "cppyy", cppyy)

    # Set up a few tricks
    cppyy.cppdef("""
        using namespace ns3;
        bool Time_ge(Time& a, Time& b){ return a >= b;}
        bool Time_eq(Time& a, Time& b){ return a == b;}
        bool Time_ne(Time& a, Time& b){ return a != b;}
        bool Time_le(Time& a, Time& b){ return a <= b;}
        bool Time_gt(Time& a, Time& b){ return a > b;}
        bool Time_lt(Time& a, Time& b){ return a < b;}
    """)
    cppyy.gbl.ns3.Time.__ge__ = cppyy.gbl.Time_ge
    cppyy.gbl.ns3.Time.__eq__ = cppyy.gbl.Time_eq
    cppyy.gbl.ns3.Time.__ne__ = cppyy.gbl.Time_ne
    cppyy.gbl.ns3.Time.__le__ = cppyy.gbl.Time_le
    cppyy.gbl.ns3.Time.__gt__ = cppyy.gbl.Time_gt
    cppyy.gbl.ns3.Time.__lt__ = cppyy.gbl.Time_lt

    # Node::~Node isn't supposed to destroy the object,
    # since it gets destroyed at the end of the simulation
    # we need to hold the reference until it gets destroyed by C++
    #
    # Search for NodeList::Add (this)
    cppyy.gbl.ns3.__nodes_pending_deletion = []

    def Node_del(self: cppyy.gbl.ns3.Node) -> None:
        cppyy.gbl.ns3.__nodes_pending_deletion.append(self)
        return None

    cppyy.gbl.ns3.Node.__del__ = Node_del

    cppyy.cppdef("""
        using namespace ns3;
        std::tuple<bool, TypeId> LookupByNameFailSafe(std::string name)
        {
            TypeId id;
            bool ok = TypeId::LookupByNameFailSafe(name, &id);
            return std::make_tuple(ok, id);
        }
    """)
    setattr(cppyy.gbl.ns3, "LookupByNameFailSafe", cppyy.gbl.LookupByNameFailSafe)

    return cppyy.gbl.ns3


# Load all modules and make them available via a built-in
ns = load_modules()  # can be imported via 'from ns import ns'
builtins.__dict__["ns"] = ns  # or be made widely available with 'from ns import *'
