import sys
import os, errno, glob
import dircache, shutil
import re
from plistlib import Plist
from distutils import dir_util, file_util

from project import *
import utils

class Bundler:
    def __init__(self, project):
        self.project = project

        self.project_dir = project.get_project_dir()

        plist_path = self.project.get_plist_path()
        self.plist = Plist.fromFile(plist_path)

        # List of paths that should be recursively searched for
        # binaries that are used to find library dependencies.
        self.binary_paths = []
        #List of frameworks moved into the bundle which need to be set
        #up for private use.
        self.frameworks = []

        # Create the bundle in a temporary location first and move it
        # to the final destination when done.
        self.meta = project.get_meta()
        self.bundle_path = os.path.join(self.meta.dest, "." + project.get_bundle_name() + ".app")

    def recursive_rm(self, dirname):
        # Extra safety ;)
        if dirname in [ "/", os.getenv("HOME"), os.path.join(os.getenv("HOME"), "Desktop"), self.meta.dest ]:
            print "Eek, trying to remove a bit much, eh? (%s)" % (dirname)
            sys.exit(1)

        if not os.path.exists(dirname):
            return
        
        files = dircache.listdir(dirname)
        for file in files:
            path = os.path.join (dirname, file)
            if os.path.isdir(path):
                self.recursive_rm(path)
            else:
                retval = os.unlink(path)
        if (os.path.islink(dirname)):
            os.unlink(dirname)
        else:
            os.rmdir(dirname)

    def create_skeleton(self):
        utils.makedirs(self.project.get_bundle_path("Contents/Resources"))
        utils.makedirs(self.project.get_bundle_path("Contents/MacOS"))

    def create_pkglist(self):
        path = self.project.get_bundle_path("Contents", "PkgInfo")
        path = self.project.evaluate_path(path)
        f = open (path, "w")
        f.write(self.plist.CFBundlePackageType)
        f.write(self.plist.CFBundleSignature)
        f.close()

    def copy_plist(self):
        path = Path(self.project.get_plist_path(),
                    self.project.get_bundle_path("Contents/Info.plist"))
        self.copy_path(path)

    def create_pango_setup(self):
        # Create a temporary pangorc file just for creating the
        # modules file with the right modules.
        module_version = utils.evaluate_pkgconfig_variables("${pkg:pango:pango_module_version}")
        modulespath = self.project.get_bundle_path("Contents/Resources/lib/pango/" +
                                                   module_version +
                                                   "modules/")

        from distutils.version import StrictVersion as V
        import tempfile

        fd, tmp_filename = tempfile.mkstemp()
        f = os.fdopen(fd, "w")
        f.write("[Pango]\n")
        f.write("ModulesPath=" + modulespath)
        f.write("\n")
        f.close()

        cmd = "PANGO_RC_FILE=" + tmp_filename + " pango-querymodules"
        f = os.popen(cmd)

        path = self.project.get_bundle_path("Contents/Resources/etc/pango")
        utils.makedirs(path)
        fout = open(os.path.join(path, "pango.modules"), "w")

        if V(module_version) < V('1.8.0'):
            prefix_path  = os.path.join("Contents", "Resources")
        else:
            prefix_path = os.path.join("Contents", "Resources", "lib", "pango",
                                       module_version, "modules/")

        prefix = self.project.get_bundle_path(prefix_path)

        for line in f:
            line = line.strip()
            if line.startswith("# ModulesPath"):
                continue

            # Replace the hardcoded bundle path with @executable_path...
            if line.startswith(prefix):
                line = line[len(prefix):]
#Newer versions of pango have been modified to look in the right place
#for modules (providing the PANGO_LIB_DIR is set to point to the
#bundle_lib folder).
                if V(module_version) < V('1.8.0'):
                    line = "@executable_path/../Resources" + line

            fout.write(line)
            fout.write("\n")
        fout.close()

        os.unlink(tmp_filename)

        # Create the final pangorc file
        path = self.project.get_bundle_path("Contents/Resources/etc/pango")
        utils.makedirs(path)
        f = open(os.path.join(path, "pangorc"), "w")
        f.write("[Pango]\n")
#Pango 2.32 (corresponding to module_version 1.8.0) and later don't
#interpret "./" to mean the same directory that the rc file is in, so
#this doesn't work any more. However, pango does know to look in the
#bundle directory (when given the right environment variable), so we
#don't need this, either.
        if V(module_version) < V('1.8.0'):
            f.write("ModuleFiles=./pango.modules\n")
        f.close()

    def create_gtk_immodules_setup(self):
        path = self.project.get_bundle_path("Contents/Resources")
        cmd = "GTK_EXE_PREFIX=" + path + " gtk-query-immodules-" + self.project.get_gtk_version()
        f = os.popen(cmd)

        path = self.project.get_bundle_path("Contents/Resources/etc/", 
                                            self.project.get_gtk_dir())
        utils.makedirs(path)
        fout = open(os.path.join(path, "gtk.immodules"), "w")

        prefix = "\"" + self.project.get_bundle_path("Contents/Resources")

        for line in f:
            line = line.strip()
            if line.startswith("#"):
                continue

            # Replace the hardcoded bundle path with @executable_path...
            if line.startswith(prefix):
                line = line[len(prefix):]
                line = "\"@executable_path/../Resources" + line
            fout.write(line)
            fout.write("\n")
        fout.close()

    def create_gdk_pixbuf_loaders_setup(self):
        modulespath = ""
        if os.path.exists(os.path.join(self.project.get_prefix(), "lib", 
                                       "gdk-pixbuf-2.0")):

            modulespath = self.project.get_bundle_path("Contents/Resources/lib/",
                                                     "gdk-pixbuf-2.0", 
                                                     "${pkg:gdk-pixbuf-2.0:gdk_pixbuf_binary_version}",
                                                     "loaders")
        elif os.path.exists(os.path.join(self.project.get_prefix(), "lib", 
                                       "gdk-pixbuf-3.0")):
            modulespath = self.project.get_bundle_path("Contents/Resources/lib/",
                                                     "gdk-pixbuf-3.0", 
                                                     "${pkg:gdk-pixbuf-3.0:gdk_pixbuf_binary_version}",
                                                     "loaders")
        else:
            modulespath = self.project.get_bundle_path("Contents/Resources/lib/",
                                                   self.project.get_gtk_dir(),
                                                   "${pkg:" + self.meta.gtk + ":gtk_binary_version}",
                                                   "loaders")
        modulespath = utils.evaluate_pkgconfig_variables (modulespath)
        cmd = "GDK_PIXBUF_MODULEDIR=" + modulespath + " gdk-pixbuf-query-loaders"
        f = os.popen(cmd)

        path = self.project.get_bundle_path("Contents/Resources/etc/", 
                                            self.project.get_gtk_dir())
        utils.makedirs(path)
        fout = open(os.path.join(path, "gdk-pixbuf.loaders"), "w")

        prefix = "\"" + self.project.get_bundle_path("Contents/Resources")
        for line in f:
            line = line.strip()
            if line.startswith("#"):
                continue

            # Replace the hardcoded bundle path with @executable_path...
            if line.startswith(prefix):
                line = line[len(prefix):]
                line = "\"@executable_path/../Resources" + line
            fout.write(line)
            fout.write("\n")
        fout.close()

    def copy_binaries(self, binaries):
        for path in binaries:
            dest = self.copy_path(path)

            self.binary_paths.append(dest)

            # Clean up duplicates
            self.binary_paths = list(set(self.binary_paths))

            # Clean out any libtool (*.la) files and static libraries
            if os.path.isdir(dest):
                for root, dirs, files in os.walk(dest):
                    for name in filter(lambda l: l.endswith(".la") or l.endswith(".a"), files):
                        os.remove(os.path.join(root, name))

    # Copies from Path.source to Path.dest, evaluating any variables
    # in the paths, and returns the real dest.
    def copy_path(self, Path):
        _doRecurse = False
        source = self.project.evaluate_path(Path.source)
        if Path.dest:
            dest = self.project.evaluate_path(Path.dest)
        else:
            # Source must begin with a prefix if we don't have a
            # dest. Skip past the source prefix and replace it with
            # the right bundle path instead.
            p = re.compile("^\${prefix(:.*?)?}/")
            m = p.match(Path.source)
            if m:
                relative_dest = self.project.evaluate_path(Path.source[m.end():])
                dest = self.project.get_bundle_path("Contents/Resources", relative_dest)
            else:
                print "Invalid bundle file, missing or invalid 'dest' property: " + Path.dest
                sys.exit(1)

        (dest_parent, dest_tail) = os.path.split(dest)
        utils.makedirs(dest_parent)

        # Check that the source only has wildcards in the last component.
        p = re.compile("[\*\?]")
        (source_parent, source_tail) = os.path.split(source)
        if p.search(source_parent):
            print "Can't have wildcards except in the last path component: " + source
            sys.exit(1)

        if p.search(source_tail):
            source_check = source_parent
            if Path.recurse:
                _doRecurse = True
        else:
            source_check = source
        if not os.path.exists(source_check):
            print "Cannot find source to copy: " + source
            sys.exit(1)

        # If the destination has a wildcard as last component (copied
        # from the source in dest-less paths), ignore the tail.
        if p.search(dest_tail):
            dest = dest_parent

        if _doRecurse:
            for root, dirs, files in os.walk(source_parent):
                destdir = os.path.join(dest,
                                       os.path.relpath(root, source_parent))
                utils.makedirs(destdir)
                for globbed_source in glob.glob(os.path.join(root,
                                                             source_tail)):
                    try:
#                        print "Copying %s to %s" % (globbed_source, destdir)
                        shutil.copy(globbed_source, destdir)
                    except EnvironmentError, e:
                        if e.errno == errno.ENOENT:
                            print "Warning, source file missing: " + globbed_source
                        elif e.errno == errno.EEXIST:
                            print "Warning, path already exits: " + dest
                        else:
                            print "Error %s when copying file: %s" % ( str(e), globbed_source )
                            sys.exit(1)

        else:
            for globbed_source in glob.glob(source):
                try:
                    if os.path.isdir(globbed_source):
                        #print "dir: %s => %s" % (globbed_source, dest)
                        dir_util.copy_tree (str(globbed_source), str(dest),
                                            preserve_mode=1,
                                            preserve_times=1,
                                            preserve_symlinks=1,
                                            update=1,
                                            verbose=1,
                                            dry_run=0)
                    else:
                        #print "file: %s => %s" % (globbed_source, dest)
                        file_util.copy_file (str(globbed_source), str(dest),
                                            preserve_mode=1,
                                            preserve_times=1,
                                            update=1,
                                            link=None,
                                            verbose=1,
                                            dry_run=0)
                except EnvironmentError, e:
                    if e.errno == errno.ENOENT:
                        print "Warning, source file missing: " + globbed_source
                    elif e.errno == errno.EEXIST:
                        print "Warning, path already exits: " + dest
                    else:
                        print "Error %s when copying file: %s" %( str(e), globbed_source )
                        sys.exit(1)
        return dest

    # Lists all the binaries copied in so far. Used in the library
    # dependency resolution and icon theme lookup.
    def list_copied_binaries(self):
        def filter_path(path):
            if os.path.islink(path):
                return False
            if path.endswith(".so") or path.endswith(".dylib") or os.access(path, os.X_OK):
                return True
            return False
        paths = []
        for path in self.binary_paths:
            if os.path.isdir(path):
                for root, dirs, files in os.walk(path):
                    paths.extend(map(lambda l: os.path.join(root, l), files))
            else:
                paths.append(path)

        paths = filter(filter_path, paths)
        return list(set(paths))

    def resolve_library_dependencies(self):
        # Get the libraries we link to, filter out anything that
        # doesn't come from any of the prefixes we have declared. Then
        # copy in the resulting list, and repeat until the list
        # doesn't change. At that point, all dependencies are
        # resolved.
        n_iterations = 0
        n_paths = 0
        paths = self.list_copied_binaries()
        while n_paths != len(paths):
            cmds = [ "otool -L " ]
            for path in paths:
                cmds.append(path + " ")

            cmd = ''.join(cmds)
            f = os.popen(cmd)

            prefixes = self.meta.prefixes

            def relative_path_map(line):
                if not os.path.isabs(line):
                    for prefix in prefixes.values():
                        path = os.path.join(prefix, "lib", line)
                        if os.path.exists(path):
                            return path
                    print "Cannot find a matching prefix for %s" % (line)
                return line

            def prefix_filter(line):
                if not "(compatibility" in line:
                    return False

                if line.startswith("/usr/X11"):
                    print "Warning, found X11 library dependency, you most likely don't want that:", line.strip().split()[0]

                if os.path.isabs(line):
                    for prefix in prefixes.values():
                        if prefix in line:
                            return True
                    
                    if not line.startswith("/usr/lib") and not line.startswith("/System/Library"):
                        print "Warning, library not available in any prefix:", line.strip().split()[0]

                    return False

                return True

            lines = filter(prefix_filter, [line.strip() for line in f])
            p = re.compile("(.*\.dylib\.?.*)\s\(compatibility.*$")
            lines = utils.filterlines(p, lines)
            lines = map(relative_path_map, lines)
#When you need to track down errors, uncomment this blocK
#            for path in paths:
#                cmd = "otool -L %s" % path
#                print path
#                f = os.popen(cmd)
#                lines = filter(prefix_filter, [line.strip() for line in f])

            new_libraries = []
            for library in set(lines):
                # Replace the real path with the right prefix so we can
                # create a Path object.
                for (key, value) in prefixes.items():
                    if library.startswith(value):
                        path = Path("${prefix:" + key + "}" + library[len(value):])
                        new_libraries.append(path)

            n_paths = len(paths)
            n_iterations += 1
            if n_iterations > 10:
                print "Too many tries to resolve library dependencies"
                sys.exit(1)
            
            self.copy_binaries(new_libraries)
            paths = self.list_copied_binaries()

    def run_install_name_tool(self):
        print "Running install name tool"

        paths = self.list_copied_binaries()
        prefixes = self.meta.prefixes

        # First change all references in libraries.
        for prefix in prefixes:
            prefix_path = self.project.get_prefix(prefix)
            print "Going through prefix: " + prefix_path
            for path in paths:
                cmd = os.path.join(os.path.dirname(__file__), "run-install-name-tool-change.sh") + " " + path + " " + prefix_path + " Resources" + " change"
                f = os.popen(cmd)
                for line in f:
                    print line

        # Then change the id of all libraries. Skipping this part for now
        #for path in paths:
        #    cmd = os.path.join(os.path.dirname(__file__), "run-install-name-tool-id.sh") + " " + path
        #    print cmd
        #    f = os.popen(cmd)
        #    for line in f:
        #        print line

        for framework in self.frameworks:
            fw_name, ext = os.path.splitext(os.path.basename(framework))
            fwl = os.path.join(framework, fw_name)
            print "Importing Framework: " + fwl
# Fix the framework IDs
            cmd = os.path.join(os.path.dirname(__file__), "run-install-name-tool-change.sh") + " " + fwl + " " + fw_name + " Frameworks" + " id"
            f = os.popen(cmd)
            for line in f:
                print line
# Fix the dependencies in other libraries
            for path in paths:
                cmd = os.path.join(os.path.dirname(__file__), "run-install-name-tool-change.sh") + " " + path + " " + fw_name + " Frameworks/" + fw_name + " change"
                f = os.popen(cmd)
                for line in f:
                    print line
#fix the dependencies in frameworks
            for ufw in self.frameworks:
                ufw_name, ext = os.path.splitext(os.path.basename(ufw))
                if ufw_name == fw_name:
                    continue
                ufwl = os.path.join(ufw, ufw_name)
                cmd = os.path.join(os.path.dirname(__file__), "run-install-name-tool-change.sh") + " " + ufwl + " " + fw_name + " Frameworks/" + fw_name + " change"
                f = os.popen(cmd)
                for line in f:
                    print line


    def strip_debugging(self):
        paths = self.list_copied_binaries()
        for path in paths:
            if path.endswith(".dylib") or path.endswith(".so"):
                os.chmod(path, 0644)
                os.system("strip -x " + path + " 2>/dev/null")
                os.chmod(path, 0444)
            else:
                os.chmod(path, 0755)
                os.system("strip -ur " + path + " 2>/dev/null")
                os.chmod(path, 0555)

#
# If you want to sign your application, set $APPLICATION_CERT with the
# appropriate certificate name in your default Keychain. This function
# will sign every binary in the bundle with the certificate and the
# bundle's id string.
#
    def sign_binaries(self):
        if not os.environ.has_key("APPLICATION_CERT"):
            return
        cert = os.getenv("APPLICATION_CERT")
        paths = self.list_copied_binaries()
        ident = self.project.get_bundle_id()
        for path in paths:
            cmdargs = ['codesign', '-s', cert, '-i', ident, path]
            result = os.spawnvp(os.P_WAIT, 'codesign', cmdargs)

            if result:
                raise OSError, '"' + " ".join(cmdargs) + '" failed %d' % result

    def copy_icon_themes(self):
        all_icons = set()

        themes = self.project.get_icon_themes()

        for theme in themes:
            self.copy_path(Path(os.path.join(theme.source, "index.theme")))

        for theme in themes:
            if theme.icons == IconTheme.ICONS_NONE:
                continue
            
            for root, dirs, files in os.walk(self.project.evaluate_path(theme.source)):
                for f in files:
                    (head, tail) = os.path.splitext(f)
                    if tail in [".png", ".svg"]:
                        all_icons.add(head)

        strings = set()

        # Get strings from binaries.
        for f in self.list_copied_binaries():
            p = os.popen("strings " + f)
            for string in p:
                string = string.strip()
                strings.add(string)

        # FIXME: Also get strings from glade files.

        used_icons = all_icons.intersection(strings)
        prefix = self.project.get_prefix()

        for theme in themes:
            if theme.icons == IconTheme.ICONS_NONE:
                continue

            for root, dirs, files in os.walk(self.project.evaluate_path(theme.source)):
                for f in files:
                    # Go through every file, if it matches the icon
                    # set, copy it.
                    (head, tail) = os.path.splitext(f)
                    if head in used_icons or theme.icons == IconTheme.ICONS_ALL:
                        path = os.path.join(root, f)

                        # Note: Skipping svgs for now, they are really
                        # big and not really used.
                        if path.endswith(".svg"):
                            continue

                        # Replace the real paths with the prefix macro
                        # so we can use copy_path.
                        self.copy_path(Path("${prefix}" + path[len(prefix):]))

        # Generate icon caches.
        for theme in themes:
            path = self.project.get_bundle_path("Contents/Resources/share/icons", theme.name)
            cmd = "gtk-update-icon-cache -f " + path + " 2>/dev/null"
            os.popen(cmd)

    def copy_translations(self):
        translations = self.project.get_translations()
        prefix = self.project.get_prefix()


        def name_filter(filename):
            path, fname = os.path.split(filename)
            name, ext = os.path.splitext(fname)
            if name != program.name:
                return False
            elif ext not in (".mo", ".po"):
                return False
            else:
                return True

        for program in translations:
            if program.name == "" or program.name == None:
                raise "No program name to tranlate!"

            source = self.project.evaluate_path(program.source)
            if source == None:
                raise "Failed to parse translation source!"
            for root, trees, files in os.walk(source):
                for file in filter(name_filter, files):
                    path = os.path.join(root, file)
                    self.copy_path(Path("${prefix}" + path[len(prefix):], 
                                        program.dest))


    def run(self):
        # Remove the temp location forcefully.
        path = self.project.evaluate_path(self.bundle_path)
        self.recursive_rm(path)

        final_path = os.path.join(self.meta.dest, self.project.get_bundle_name() + ".app")
        final_path = self.project.evaluate_path(final_path)

        if not self.meta.overwrite and os.path.exists(final_path):
            print "Bundle already exists: " + final_path
            sys.exit(1)

        self.create_skeleton()
        self.create_pkglist()
        self.copy_plist()

        # Note: could move this to xml file...
        self.copy_path(Path("${prefix}/lib/charset.alias"))

        # Main binary
        path = self.project.get_main_binary()
        source = self.project.evaluate_path(path.source)
        if not os.path.exists(source):
            print "Cannot find main binary: " + source
            sys.exit(1)

        dest = self.copy_path(path)
        self.binary_paths.append(dest)

        # Additional binaries (executables, libraries, modules)
        self.copy_binaries(self.project.get_binaries())
        self.resolve_library_dependencies()

        # Data
        for path in self.project.get_data():
            self.copy_path(path)
        
        # Translations
        self.copy_translations()

        # Frameworks
        frameworks = self.project.get_frameworks()
        for path in frameworks:
            dest = self.copy_path(path)
            self.frameworks.append(dest)

        self.copy_icon_themes()

        self.create_pango_setup()
        self.create_gtk_immodules_setup()
        self.create_gdk_pixbuf_loaders_setup()

        if self.meta.run_install_name_tool:
            self.run_install_name_tool()

        #self.strip_debugging()

        self.sign_binaries()

        # Launcher script, if necessary.
        launcher_script = self.project.get_launcher_script()
        if launcher_script:
            path = self.copy_path(launcher_script)
            if os.environ.has_key("APPLICATION_CERT"):
                cert = os.environ["APPLICATION_CERT"]
                ident = self.project.get_bundle_id()
                cmdargs = ['codesign', '-s', cert, '-i', ident, "-f", path]
                result = os.spawnvp(os.P_WAIT, 'codesign', cmdargs)
                if result:
                    raise OSError, '"'+ " ".join(cmdargs) + '" failed %d' % result
        if self.meta.overwrite:
            self.recursive_rm(final_path)
        shutil.move(self.project.get_bundle_path(), final_path)

if __name__ == '__main__':
    if len(sys.argv) != 2:
        print "Usage: %s <bundle descriptopn file>" % (sys.argv[0])
        sys.exit(2)

    if not os.path.exists(sys.argv[1]):
        print "File %s does not exist" % (sys.argv[1])
        sys.exit(2)

    project = Project(sys.argv[1])
    bundler = Bundler(project)

    bundler.run()
