#! /usr/bin/env python

import os
import sys
import shutil
import platform
import tempfile
from optparse import OptionParser
import subprocess

try:
    from hashlib import sha1 as sha
except ImportError:
    from sha import sha

usage = """
This command creates a graphical installer for the
Panda3D plugin and runtime environment.

  %prog [opts]"""

parser = OptionParser(usage = usage)
parser.add_option('-n', '--short', dest = 'short_name',
                  help = 'The product short name',
                  default = 'Panda3D')
parser.add_option('-N', '--long', dest = 'long_name',
                  help = 'The product long name',
                  default = 'Panda3D Game Engine')
parser.add_option('-v', '--version', dest = 'version',
                  help = 'The product version',
                  default = None)
parser.add_option('-p', '--publisher', dest = 'publisher',
                  help = 'The name of the publisher',
                  default = 'Carnegie Mellon Entertainment Technology Center')
parser.add_option('', '--install', dest = 'install_dir',
                  help = "The install directory on the user's machine (Windows only)",
                  default = '$PROGRAMFILES\\Panda3D')
parser.add_option('-l', '--license', dest = 'license',
                  help = 'A file containing the license or EULA text',
                  default = None)
parser.add_option('-w', '--website', dest = 'website',
                  help = 'The product website',
                  default = 'http://www.panda3d.org')
parser.add_option('', '--start', dest = 'start',
                  help = 'Specify this option to add a start menu',
                  action = 'store_true', default = False)
parser.add_option('', '--welcome_image', dest = 'welcome_image',
                  help = 'The image to display on the installer, 170x312 BMP',
                  default = None)
parser.add_option('', '--install_icon', dest = 'install_icon',
                  help = 'The icon to give to the installer',
                  default = None)
parser.add_option('', '--nsis', dest = 'nsis',
                  help = 'The path to the makensis executable',
                  default = None)
parser.add_option('', '--cab', dest = 'cab',
                  help = 'Generate an ActiveX CAB file (Windows only).  If --spc and --pvk are not also specified, the CAB file will be unsigned.',
                  action = 'store_true', default = False)
parser.add_option('', '--spc', dest = 'spc',
                  help = 'Sign the CAB file generated by --cab with the indicated spc file (Windows only).  You must also specify --pvk.',
                  default = None)
parser.add_option('', '--pvk', dest = 'pvk',
                  help = 'Specifies the private key to be used in conjuction with --spc to sign a CAB file (Windows only).',
                  default = None)
parser.add_option('', '--mssdk', dest = 'mssdk',
                  help = 'The path to the MS Platform SDK directory (Windows only).  mssdk/bin should contain cabarc.exe and signcode.exe.',
                  default = None)

(options, args) = parser.parse_args()

this_dir = os.path.split(sys.argv[0])[0]

assert options.version, "A version number must be supplied!"

##############################################################################
#
# This Info.plist file is used only for the OSX 10.4 version of packagemaker.
#
##############################################################################

Info_plist = """<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>CFBundleIdentifier</key>
  <string>%(package_id)s</string>
  <key>CFBundleShortVersionString</key>
  <string>%(version)s</string>
  <key>IFPkgFlagRelocatable</key>
  <false/>
  <key>IFPkgFlagAuthorizationAction</key>
  <string>RootAuthorization</string>
</dict>
</plist>
"""

##############################################################################
#
# This Description.plist file is used only for the OSX 10.4 version of packagemaker.
#
##############################################################################

Description_plist = """<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>IFPkgDescriptionDescription</key>
  <string></string>
  <key>IFPkgDescriptionTitle</key>
  <string>%(long_name)s</string>
</dict>
</plist>
"""


##############################################################################
#
# Locate the relevant files.
#
##############################################################################

def findExecutable(filename):
    """ Searches for the named .exe or .dll file along the system PATH
    and returns its full path if found, or None if not found. """
    
    if sys.platform == "win32":
        for p in os.defpath.split(";") + os.environ["PATH"].split(";"):
            if os.path.isfile(os.path.join(p, filename)):
                return os.path.join(p, filename)
    else:
        for p in os.defpath.split(":") + os.environ["PATH"].split(":"):
            if os.path.isfile(os.path.join(p, filename)):
                return os.path.join(p, filename)
    return None
    

if not options.nsis:
    makensis = findExecutable('makensis.exe')
    if sys.platform == "win32":
        if not makensis:
            try:
                import pandac
                makensis = os.path.dirname(os.path.dirname(pandac.__file__))
                makensis = os.path.join(makensis, "nsis", "makensis.exe")
                if not os.path.isfile(makensis):
                    makensis = None
            except ImportError: pass
        if not makensis:
            thirdparty = os.environ.get("MAKEPANDA_THIRDPARTY", "thirdparty")
            makensis = os.path.join(thirdparty, "win-nsis", "makensis.exe")
            if not os.path.isfile(makensis):
                makensis = None
        options.nsis = makensis

if not options.license:
    try:
        import pandac
        options.license = os.path.join(os.path.dirname(os.path.dirname(pandac.__file__)), "LICENSE")
        if not os.path.isfile(options.license):
            options.license = None
    except: pass
    if not options.license:
        options.license = os.path.join("doc", "LICENSE")
        if not os.path.isfile(options.license):
            options.license = None
    if options.license:
        options.license = os.path.abspath(options.license)

if sys.platform == "win32" and not options.welcome_image:
    filename = os.path.join('models', 'plugin_images', 'installer.bmp')
    if not os.path.exists(filename):
        sys.exit("Couldn't find installer.bmp for welcome_image.")
    options.welcome_image = os.path.abspath(filename)

def parseDependenciesWindows(tempFile):
    """ Reads the indicated temporary file, the output from
    dumpbin /dependents, to determine the list of dll's this
    executable file depends on. """

    lines = open(tempFile, 'rU').readlines()
    li = 0
    while li < len(lines):
        line = lines[li]
        li += 1
        if line.find(' has the following dependencies') != -1:
            break

    if li < len(lines):
        line = lines[li]
        if line.strip() == '':
            # Skip a blank line.
            li += 1

    # Now we're finding filenames, until the next blank line.
    filenames = []
    while li < len(lines):
        line = lines[li]
        li += 1
        line = line.strip()
        if line == '':
            # We're done.
            return filenames
        filenames.append(line)

    # Hmm, we ran out of data.  Oh well.
    if not filenames:
        # Some parse error.
        return None

    # At least we got some data.
    return filenames

def parseDependenciesUnix(tempFile):
    """ Reads the indicated temporary file, the output from
    otool -XL or ldd, to determine the list of dll's this
    executable file depends on. """
    
    lines = open(tempFile, 'rU').readlines()
    filenames = []
    for l in lines:
        filenames.append(l.strip().split(' ', 1)[0])
    return filenames

def addDependencies(path, pathname, file, pluginDependencies, dependentFiles):
    """ Checks the named file for DLL dependencies, and adds any
    appropriate dependencies found into pluginDependencies and
    dependentFiles. """
    
    tempFile = tempfile.mktemp('.txt', 'p3d_')
    if sys.platform == "darwin":
        command = 'otool -XL "%s" >"%s"'
    elif sys.platform == "win32":
        command = 'dumpbin /dependents "%s" >"%s"'
    else:
        command = 'ldd "%s" >"%s"'
    command = command % (pathname, tempFile)
    try:
        os.system(command)
    except:
        pass
    filenames = None

    if os.path.isfile(tempFile):
        if sys.platform == "win32":
            filenames = parseDependenciesWindows(tempFile)
        else:
            filenames = parseDependenciesUnix(tempFile)
        os.unlink(tempFile)
    if filenames is None:
        sys.exit("Unable to determine dependencies from %s" % (pathname))

    # Look for MSVC[RP]*.dll, and MFC*.dll.  These dependent files
    # have to be included too.  Also, any Panda-based libraries, or
    # the Python DLL, should be included, in case panda3d.exe wasn't
    # built static.  The Panda-based libraries begin with "lib" and
    # are all lowercase, or start with libpanda/libp3d.
    for dfile in filenames:
        dfilelower = dfile.lower()
        if dfilelower not in dependentFiles:
            if dfilelower.startswith('msvc') or \
               dfilelower.startswith('mfc') or \
               dfilelower.startswith('zlib1') or \
               (dfile.startswith('lib') and dfile == dfilelower) or \
               dfilelower.startswith('libpanda') or \
               dfilelower.startswith('libp3d') or \
               dfilelower.startswith('python'):
                pathname = None
                for pitem in path:
                    pathname = os.path.join(pitem, dfile)
                    if os.path.exists(pathname):
                        break
                    pathname = None
                if not pathname:
                    sys.exit("Couldn't find %s." % (dfile))
                pathname = os.path.abspath(pathname)
                dependentFiles[dfilelower] = pathname

                # Also recurse.
                addDependencies(path, pathname, file, pluginDependencies, dependentFiles)

        if dfilelower in dependentFiles and dfilelower not in pluginDependencies[file]:
            pluginDependencies[file].append(dfilelower)

def getDllVersion(filename):
    """ Returns the DLL version number in the indicated DLL, as a
    string of comma-separated integers.  Windows only. """

    # This relies on the VBScript program in the same directory as
    # this script.
    thisdir = os.path.split(sys.argv[0])[0]
    versionInfo = os.path.join(thisdir, 'VersionInfo.vbs')
    tempfile = 'tversion.txt'
    tempdata = open(tempfile, 'w+')
    cmd = 'cscript //nologo "%s" "%s"' % (versionInfo, filename)
    print cmd
    result = subprocess.call(cmd, stdout = tempdata)
    if result:
        sys.exit(result)

    tempdata.seek(0)
    data = tempdata.read()
    tempdata.close()
    os.unlink(tempfile)
    return ','.join(data.strip().split('.'))
    

def makeCabFile(ocx, pluginDependencies):
    """ Creates an ActiveX CAB file.  Windows only. """

    ocxFullpath = findExecutable(ocx)
    cabFilename = os.path.splitext(ocx)[0] + '.cab'
    
    cabarc = 'cabarc'
    signcode = 'signcode'
    if options.mssdk:
        cabarc = options.mssdk + '/bin/cabarc'
        signcode = options.mssdk + '/bin/signcode'

    # First, we must generate an INF file.
    infFile = 'temp.inf'
    inf = open(infFile, 'w')

    print >> inf, '[Add.Code]\n%s=%s\n%s=%s' % (infFile, infFile, ocx, ocx)
    dependencies = pluginDependencies[ocx]
    for filename in dependencies:
        print >> inf, '%s=%s' % (filename, filename)
    print >> inf, '\n[%s]\nfile=thiscab' % (infFile)
    print >> inf, '\n[%s]\nfile=thiscab\nclsid={924B4927-D3BA-41EA-9F7E-8A89194AB3AC}\nRegisterServer=yes\nFileVersion=%s' % (ocx, getDllVersion(ocxFullpath))

    fullpaths = []
    for filename in dependencies:
        fullpath = findExecutable(filename)
        fullpaths.append(fullpath)
        print >> inf, '\n[%s]\nfile=thiscab\nDestDir=11\nRegisterServer=yes\nFileVersion=%s' % (filename, getDllVersion(fullpath))
    inf.close()

    # Now process the inf file with cabarc.
    try:
        os.unlink(cabFilename)
    except OSError:
        pass
    
    cmd = '"%s" -s 6144 n "%s"' % (cabarc, cabFilename)
    for fullpath in fullpaths:
        cmd += ' "%s"' % (fullpath)
    cmd += ' "%s" %s' % (ocxFullpath, infFile)
    print cmd
    result = subprocess.call(cmd)
    if result:
        sys.exit(result)
    if not os.path.exists(cabFilename):
        print "Couldn't generate %s" % (cabFilename)
        sys.exit(1)
        
    print "Successfully generated %s" % (cabFilename)

    if options.spc and options.pvk:
        # Now we have to sign the cab file.
        cmd = '"%s" -spc "%s" -k "%s" "%s"' % (signcode, options.spc, options.pvk, cabFilename)
        print cmd
        result = subprocess.call(cmd)
        if result:
            sys.exit(result)
    
def makeInstaller():
    # Locate the plugin(s).
    pluginFiles = {}
    pluginDependencies = {}
    dependentFiles = {}

    # These are the primary files that make
    # up the plugin/runtime.
    if sys.platform == "darwin":
        npapi = 'nppanda3d.plugin'
        panda3d = 'panda3d'
        panda3dapp = 'Panda3D.app'
        baseFiles = [npapi, panda3d, panda3dapp]
    elif sys.platform == 'win32':
        ocx = 'p3dactivex.ocx'
        npapi = 'nppanda3d.dll'
        panda3d = 'panda3d.exe'
        panda3dw = 'panda3dw.exe'
        baseFiles = [ocx, npapi, panda3d, panda3dw]
    else:
        baseFiles = []

    path = []
    pathsep = ':'
    if sys.platform == "win32":
        pathsep = ';'
    if 'PATH' in os.environ:
        path += os.environ['PATH'].split(pathsep)
    if sys.platform != "win32" and 'LD_LIBRARY_PATH' in os.environ:
        path += os.environ['LD_LIBRARY_PATH'].split(pathsep)
    if sys.platform == "darwin" and 'DYLD_LIBRARY_PATH' in os.environ:
        path += os.environ['DYLD_LIBRARY_PATH'].split(pathsep)
    path += os.defpath.split(pathsep)
    for file in baseFiles:
        pathname = None
        for pitem in path:
            pathname = os.path.join(pitem, file)
            if os.path.exists(pathname):
                break
            pathname = None
        if not pathname:
            sys.exit("Couldn't find %s." % (file))

        pathname = os.path.abspath(pathname)
        pluginFiles[file] = pathname
        pluginDependencies[file] = []

        if sys.platform == "win32":
            # Also look for the dll's that these plugins reference.
            addDependencies(path, pathname, file, pluginDependencies, dependentFiles)

    if sys.platform == "darwin":
        tmproot = "/var/tmp/Panda3D Runtime/"

        # Apparently, we have to rename this package with each
        # version, or Snow Leopard won't think the versions are
        # increasing.  I don't really understand why this is so.  It
        # might be related to the use of the Tiger PackageMaker's
        # output being run on Snow Leopard.  Whatever.  Numbering the
        # package files works around the problem and doesn't seem to
        # cause additional problems, so we'll do that.
        pkgname = 'p3d-setup-%s.pkg' % (options.version)
        
        if os.path.exists(tmproot):
            shutil.rmtree(tmproot)
        if os.path.isfile(pkgname):
            os.remove(pkgname)
        elif os.path.isdir(pkgname):
            shutil.rmtree(pkgname)
        if not os.path.exists(tmproot):
            os.makedirs(tmproot)
        dst_npapi = os.path.join(tmproot, "Library", "Internet Plug-Ins", npapi)
        dst_panda3d = os.path.join(tmproot, "usr", "bin", panda3d)
        dst_panda3dapp = os.path.join(tmproot, "Applications", panda3dapp)
        if not os.path.exists(dst_npapi): os.makedirs(os.path.dirname(dst_npapi))
        if not os.path.exists(dst_panda3d): os.makedirs(os.path.dirname(dst_panda3d))
        if not os.path.exists(dst_panda3dapp): os.makedirs(os.path.dirname(dst_panda3dapp))
        shutil.copytree(pluginFiles[npapi], dst_npapi)
        shutil.copyfile(pluginFiles[panda3d], dst_panda3d)
        os.chmod(dst_panda3d, 0o755)
        shutil.copytree(pluginFiles[panda3dapp], dst_panda3dapp)
        
        tmpresdir = tempfile.mktemp('', 'p3d-resources')
        if os.path.exists(tmpresdir):
            shutil.rmtree(tmpresdir)
        os.makedirs(tmpresdir)
        if options.license:
            shutil.copyfile(options.license, os.path.join(tmpresdir, "License.txt"))

        package_id = 'org.panda3d.pkg.runtime' #TODO: maybe more customizable?

        infoFilename = None
        descriptionFilename = None
        packagemaker = "/Developer/usr/bin/packagemaker"
        if os.path.exists(packagemaker):
            # PackageMaker 3.0 or better, e.g. OSX 10.5.
            CMD = packagemaker
            CMD += ' --id "%s"' % package_id
            CMD += ' --version "%s"' % options.version
            CMD += ' --title "%s"' % options.long_name
            CMD += ' --out "%s"' % (pkgname)
            CMD += ' --target 10.4' # The earliest version of OSX supported by Panda
            CMD += ' --domain system'
            CMD += ' --root "%s"' % tmproot
            CMD += ' --resources "%s"' % tmpresdir
            CMD += ' --no-relocate'
        else:
            # PackageMaker 2.0, e.g. OSX 10.4.
            packagemaker = "/Developer/Tools/packagemaker"
            infoFilename = '/tmp/Info_plist'
            info = open(infoFilename, 'w')
            info.write(Info_plist % {
                'package_id' : package_id,
                'version' : options.version,
                })
            info.close()
            descriptionFilename = '/tmp/Description_plist'
            description = open(descriptionFilename, 'w')
            description.write(Description_plist % {
                'long_name' : options.long_name,
                'short_name' : options.short_name,
                })
            description.close()
            CMD = packagemaker
            CMD += ' -build'
            CMD += ' -f "%s"' % (tmproot)
            CMD += ' -r "%s"' % (tmpresdir)
            CMD += ' -p "%s"' % (pkgname)
            CMD += ' -i "%s"' % (infoFilename)
            CMD += ' -d "%s"' % (descriptionFilename)
        
        print ""
        print CMD

        # Don't check the exit status of packagemaker; it's not always
        # reliable.
        subprocess.call(CMD, shell = True)
        shutil.rmtree(tmproot)

        if infoFilename:
            os.unlink(infoFilename)
        if descriptionFilename:
            os.unlink(descriptionFilename)
        if os.path.exists(tmpresdir):
            shutil.rmtree(tmpresdir)

        if not os.path.exists(pkgname):
            print "Unable to create %s." % (pkgname)
            sys.exit(1)
        
        # Pack the .pkg into a .dmg
        if not os.path.exists(tmproot): os.makedirs(tmproot)
        shutil.copytree(pkgname, os.path.join(tmproot, pkgname))
        tmpdmg = tempfile.mktemp('', 'p3d-setup') + ".dmg"
        CMD = 'hdiutil create "%s" -srcfolder "%s"' % (tmpdmg, tmproot)
        print ""
        print CMD
        result = subprocess.call(CMD, shell = True)
        if result:
            sys.exit(result)
        shutil.rmtree(tmproot)
        
        # Compress the .dmg (and make it read-only)
        if os.path.exists("p3d-setup.dmg"):
            os.remove("p3d-setup.dmg")
        CMD = 'hdiutil convert "%s" -format UDBZ -o "p3d-setup.dmg"' % tmpdmg
        print ""
        print CMD
        result = subprocess.call(CMD, shell = True)
        if result:
            sys.exit(result)
        
    elif sys.platform == 'win32':
        # Now build the NSIS command.
        CMD = "\"" + options.nsis + "\" /V3 "
        CMD += '/DPRODUCT_NAME="' + options.long_name + '" '
        CMD += '/DPRODUCT_NAME_SHORT="' + options.short_name + '" '
        CMD += '/DPRODUCT_PUBLISHER="' + options.publisher + '" '
        CMD += '/DPRODUCT_WEB_SITE="' + options.website + '" '
        CMD += '/DPRODUCT_VERSION="' + options.version + '" '
        CMD += '/DINSTALL_DIR="' + options.install_dir + '" '
        CMD += '/DLICENSE_FILE="' + options.license + '" '
        CMD += '/DOCX="' + ocx + '" '
        CMD += '/DOCX_PATH="' + pluginFiles[ocx] + '" '
        CMD += '/DNPAPI="' + npapi + '" '
        CMD += '/DNPAPI_PATH="' + pluginFiles[npapi] + '" '
        CMD += '/DPANDA3D="' + panda3d + '" '
        CMD += '/DPANDA3D_PATH="' + pluginFiles[panda3d] + '" '
        CMD += '/DPANDA3DW="' + panda3dw + '" '
        CMD += '/DPANDA3DW_PATH="' + pluginFiles[panda3dw] + '" '

        dependencies = dependentFiles.items()
        for i in range(len(dependencies)):
            CMD += '/DDEP%s="%s" ' % (i, dependencies[i][0])
            CMD += '/DDEP%sP="%s" ' % (i, dependencies[i][1])
        dependencies = pluginDependencies[npapi]
        for i in range(len(dependencies)):
            CMD += '/DNPAPI_DEP%s="%s" ' % (i, dependencies[i])

        if options.start:
            CMD += '/DADD_START_MENU '
        
        if options.welcome_image:
            CMD += '/DMUI_WELCOMEFINISHPAGE_BITMAP="' + options.welcome_image + '" '
            CMD += '/DMUI_UNWELCOMEFINISHPAGE_BITMAP="' + options.welcome_image + '" '
        if options.install_icon:
            CMD += '/DINSTALL_ICON="' + options.install_icon + '" '

        CMD += '"' + this_dir + '\\p3d_installer.nsi"' 
        
        print ""
        print CMD
        print "packing..."
        result = subprocess.call(CMD)
        if result:
            sys.exit(result)

        if options.cab:
            # Generate a CAB file and optionally sign it.
            makeCabFile(ocx, pluginDependencies)

makeInstaller()