import os
import sys
from subprocess import Popen, PIPE, check_call, check_output
import pprint
import re

# This script is intended to help copy dynamic libraries used by FreeCAD into
# a Mac application bundle and change dyld commands as appropriate.  There are
# two key items that this currently does differently from other similar tools:
#
#  * @rpath is used rather than @executable_path because the libraries need to
#    be loadable through a Python interpreter and the FreeCAD binaries.
#  * We need to be able to add multiple rpaths in some libraries.

# Assume any libraries in these paths don't need to be bundled
systemPaths = [ "/System/", "/usr/lib/",
                "/Library/Frameworks/3DconnexionClient.framework/" ]

# If a library is in these paths, but not systemPaths, a warning will be
# issued and it will NOT be bundled.  Generally, libraries installed by
# MacPorts or Homebrew won't end up in /Library/Frameworks, so we assume
# that libraries found there aren't meant to be bundled.
warnPaths = ["/Library/Frameworks/"]

class LibraryNotFound(Exception):
    pass

class Node:
    """
    self.path should be an absolute path to self.name
    """
    def __init__(self, name, path="", children=None):
        self.name = name
        self.path = path
        if not children:
            children = list()
        self.children = children
        self._marked = False
    def __eq__(self, other):
        if not isinstance(other, Node):
            return False
        return self.name == other.name
    def __ne__(self, other):
        return not self.__eq__(other)
    def __hash__(self):
        return hash(self.name)
        
class DepsGraph:
    graph = {}

    def in_graph(self, node):
        return node.name in self.graph.keys()
    
    def add_node(self, node):
        self.graph[node.name] = node

    def get_node(self, name):
        if self.graph.has_key(name):
            return self.graph[name]
        return None

    def visit(self, operation, op_args=[]):
        """"
        Preform a depth first visit of the graph, calling operation
        on each node.
        """
        stack = []
          
        for k in self.graph.keys():
            self.graph[k]._marked = False
            
        for k in self.graph.keys():
            if not self.graph[k]._marked:
                stack.append(k)
                while stack:
                    node_key = stack.pop()
                    self.graph[node_key]._marked = True
                    for ck in self.graph[node_key].children:
                        if not self.graph[ck]._marked:
                            stack.append(ck)
                    operation(self, self.graph[node_key], *op_args)


def is_macho(path):
    output = check_output(["file", path])
    if output.count("Mach-O") != 0:
        return True
    
    return False

def is_system_lib(lib):
    for p in systemPaths:
        if lib.startswith(p):
            return True
    for p in warnPaths:
        if lib.startswith(p):
            print "WARNING: library %s will not be bundled!" % lib
            print "See MakeMacRelocatable.py for more information."
            return True
    return False

def get_path(name, search_paths):
    for path in search_paths:
        if os.path.isfile(os.path.join(path, name)):
            return path
    return None

def list_install_names(path_macho):
    output = check_output(["otool", "-L", path_macho])
    lines = output.split("\t")
    libs = []

    #first line is the the filename, and if it is a library, the second line
    #is the install name of it
    if path_macho.endswith(os.path.basename(lines[1].split(" (")[0])):
        lines = lines[2:]
    else:
        lines = lines[1:]

    for line in lines:
        lib = line.split(" (")[0]
        if not is_system_lib(lib):
            libs.append(lib)
    return libs

def library_paths(install_names, search_paths):
    paths = []
    for name in install_names:
        path = os.path.dirname(name)
        lib_name = os.path.basename(name)
            
        if path == "" or name[0] == "@":
            #not absolute -- we need to find the path of this lib
            path = get_path(lib_name, search_paths)

        paths.append(os.path.join(path, lib_name))

    return paths

def create_dep_nodes(install_names, search_paths):
    """
    Return a list of Node objects from the provided install names.
    """
    nodes = []
    for lib in install_names:
        install_path = os.path.dirname(lib)
        lib_name = os.path.basename(lib)
        
        #even if install_path is absolute, see if library can be found by 
        #searching search_paths, so that we have control over what library
        #location to use 
        path = get_path(lib_name, search_paths)

        if install_path != "" and lib[0] != "@":
            #we have an absolute path install name
            if not path:
                path = install_path
       
        if not path:
            raise LibraryNotFound(lib_name + "not found in given paths")

        nodes.append(Node(lib_name, path))
    
    return nodes

def paths_at_depth(prefix, paths, depth):
    filtered = []
    for p in paths:
        dirs = os.path.join(prefix, p).strip('/').split('/')
        if len(dirs) == depth:
            filtered.append(p)
    return filtered
    
def should_visit(prefix, path_filters, path):
    s_path = path.strip('/').split('/')
    filters = []
    #we only want to use filters if they have the same parent as path 
    for rel_pf in path_filters:
        pf = os.path.join(prefix, rel_pf)
        if os.path.split(pf)[0] == os.path.split(path)[0]:
            filters.append(pf)
    if not filters:
        #no filter that applies to this path
        return True

    for pf in filters:
        s_filter = pf.strip('/').split('/')
        length = len(s_filter)
        matched = 0
	for i in range(len(s_path)):
	    if s_path[i] == s_filter[i]:
		matched += 1
            if matched == length or matched == len(s_path):
                return True
        
    return False

def build_deps_graph(graph, bundle_path, dirs_filter=None, search_paths=[]):
    """
    Walk bundle_path and build a graph of the encountered Mach-O binaries
    and there dependencies
    """
    #make a local copy since we add to it
    s_paths = list(search_paths)
    
    visited = {}

    for root, dirs, files in os.walk(bundle_path):
        if dirs_filter != None:
            dirs[:] = [d for d in dirs if should_visit(bundle_path, dirs_filter,
                                                       os.path.join(root, d))]

        s_paths.insert(0, root)
        
        for f in files:
            fpath = os.path.join(root, f)
            ext = os.path.splitext(f)[1]
            if ( (ext == "" and is_macho(fpath)) or
                 ext == ".so" or ext == ".dylib" ):
                visited[fpath] = False

    stack = []
    for k in visited.keys():
        if not visited[k]:
            stack.append(k)
            while stack:
                k2 = stack.pop()
                visited[k2] = True

                node = Node(os.path.basename(k2), os.path.dirname(k2))
                if not graph.in_graph(node):
                    graph.add_node(node)
                 
                deps = create_dep_nodes(list_install_names(k2), s_paths)
                for d in deps:
                    if d.name not in node.children:
                        node.children.append(d.name)

                    dk = os.path.join(d.path, d.name)
                    if dk not in visited.keys():
                        visited[dk] = False
                    if not visited[dk]:
                        stack.append(dk)

def in_bundle(lib, bundle_path):
    if lib.startswith(bundle_path):
        return True
    return False

def copy_into_bundle(graph, node, bundle_path):
    if not in_bundle(node.path, bundle_path):
        check_call([ "cp", "-L", os.path.join(node.path, node.name),
                                 os.path.join(bundle_path, "lib", node.name) ])

        node.path = os.path.join(bundle_path, "lib")
        
        #fix permissions
        check_call([ "chmod", "a+w",
                     os.path.join(bundle_path, "lib", node.name) ])

def get_rpaths(library):
    "Returns a list of rpaths specified within library"

    out = check_output(["otool", "-l", library])

    pathRegex = r"^path (.*) \(offset \d+\)$"
    expectingRpath = False
    rpaths = []
    for line in out.split('\n'):
        line = line.strip()

        if "cmd LC_RPATH" in line:
            expectingRpath = True
        elif "Load command" in line:
            expectingRpath = False
        elif expectingRpath:
            m = re.match(pathRegex, line)
            if m:
                rpaths.append(m.group(1))
                expectingRpath = False

    return rpaths

def add_rpaths(graph, node, bundle_path):
    if node.children:
        lib = os.path.join(node.path, node.name)
        if in_bundle(lib, bundle_path):
            install_names = list_install_names(lib)
            rpaths = []

            for install_name in install_names:
                name = os.path.basename(install_name)
                #change install names to use rpaths
                check_call([ "install_name_tool", "-change",
                    install_name, "@rpath/" + name, lib ])
                  
                dep_node = node.children[node.children.index(name)]
                rel_path = os.path.relpath(graph.get_node(dep_node).path,
                                           node.path)
                rpath = ""
                if rel_path == ".":
                    rpath = "@loader_path/"
                else:
                    rpath = "@loader_path/" + rel_path + "/"
                if rpath not in rpaths:
                    rpaths.append(rpath)

            for rpath in rpaths:
                # Ensure that lib has rpath set
                if not rpath in get_rpaths(lib):
                    check_output([ "install_name_tool",
                                   "-add_rpath", rpath, lib ])

def main():
    if len(sys.argv) < 2:
        print "Usage " + sys.argv[0] + " path [additional search paths]"
        quit()

    path = sys.argv[1]
    bundle_path = os.path.abspath(os.path.join(path, "Contents"))
    graph = DepsGraph()
    dir_filter = ["bin", "lib", "Mod", "Mod/PartDesign", 
                  "lib/python2.7/site-packages",
                  "lib/python2.7/lib-dynload"]
    search_paths = [bundle_path + "/lib"] + sys.argv[2:]

    build_deps_graph(graph, bundle_path, dir_filter, search_paths)

    graph.visit(copy_into_bundle, [bundle_path])
    graph.visit(add_rpaths, [bundle_path])

if __name__ == "__main__":
    main()
