# ##### BEGIN GPL LICENSE BLOCK #####
#
#  This program is free software; you can redistribute it and/or
#  modify it under the terms of the GNU General Public License
#  as published by the Free Software Foundation; either version 2
#  of the License, or (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program; if not, write to the Free Software Foundation,
#  Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####

import bpy
import os
import tempfile
import shutil
import tarfile
import time
import stat


bl_info = {
    "name": "Game Engine Publishing",
    "author": "Mitchell Stokes (Moguri)",
    "version": (0, 1, 0),
    "blender": (2, 72, 0),
    "location": "Render Properties > Publishing Info",
    "description": "Publish .blend file as game engine runtime, manage versions and platforms",
    "warning": "beta",
    "wiki_url": "http://wiki.blender.org/index.php/Extensions:2.6/Py/Scripts/Game_Engine/Publishing",
    "category": "Game Engine",
}


def WriteRuntime(player_path, output_path, asset_paths, copy_python, overwrite_lib, copy_dlls, make_archive, report=print):
    import struct

    player_path = bpy.path.abspath(player_path)
    ext = os.path.splitext(player_path)[-1].lower()
    output_path = bpy.path.abspath(output_path)
    output_dir = os.path.dirname(output_path)
    if not os.path.exists(output_dir):
        os.makedirs(output_dir)

    python_dir = os.path.join(os.path.dirname(player_path),
                              bpy.app.version_string.split()[0],
                              "python",
                              "lib")

    # Check the paths
    if not os.path.isfile(player_path) and not(os.path.exists(player_path) and player_path.endswith('.app')):
        report({'ERROR'}, "The player could not be found! Runtime not saved")
        return

    # Check if we're bundling a .app
    if player_path.lower().endswith('.app'):
        # Python doesn't need to be copied for OS X since it's already inside blenderplayer.app
        copy_python = False

        output_path = bpy.path.ensure_ext(output_path, '.app')

        if os.path.exists(output_path):
            shutil.rmtree(output_path)

        shutil.copytree(player_path, output_path)
        bpy.ops.wm.save_as_mainfile(filepath=os.path.join(output_path, 'Contents', 'Resources', 'game.blend'),
                                    relative_remap=False,
                                    compress=False,
                                    copy=True,
                                    )
    else:
        # Enforce "exe" extension on Windows
        if player_path.lower().endswith('.exe'):
            output_path = bpy.path.ensure_ext(output_path, '.exe')

        # Get the player's binary and the offset for the blend
        file = open(player_path, 'rb')
        player_d = file.read()
        offset = file.tell()
        file.close()

        # Create a tmp blend file (Blenderplayer doesn't like compressed blends)
        tempdir = tempfile.mkdtemp()
        blend_path = os.path.join(tempdir, bpy.path.clean_name(output_path))
        bpy.ops.wm.save_as_mainfile(filepath=blend_path,
                                    relative_remap=False,
                                    compress=False,
                                    copy=True,
                                    )

        # Get the blend data
        blend_file = open(blend_path, 'rb')
        blend_d = blend_file.read()
        blend_file.close()

        # Get rid of the tmp blend, we're done with it
        os.remove(blend_path)
        os.rmdir(tempdir)

        # Create a new file for the bundled runtime
        output = open(output_path, 'wb')

        # Write the player and blend data to the new runtime
        print("Writing runtime...", end=" ", flush=True)
        output.write(player_d)
        output.write(blend_d)

        # Store the offset (an int is 4 bytes, so we split it up into 4 bytes and save it)
        output.write(struct.pack('BBBB', (offset >> 24) & 0xFF,
                                 (offset >> 16) & 0xFF,
                                 (offset >> 8) & 0xFF,
                                 (offset >> 0) & 0xFF))

        # Stuff for the runtime
        output.write(b'BRUNTIME')
        output.close()

        print("done", flush=True)

    # Make sure the runtime is executable
    os.chmod(output_path, 0o755)

    # Copy bundled Python
    blender_dir = os.path.dirname(player_path)

    if copy_python:
        print("Copying Python files...", end=" ", flush=True)
        py_folder = os.path.join(bpy.app.version_string.split()[0], "python", "lib")
        dst = os.path.join(output_dir, py_folder)
        src = python_dir

        if os.path.exists(dst) and overwrite_lib:
            shutil.rmtree(dst)

        if not os.path.exists(dst):
            shutil.copytree(src, dst, ignore=lambda dir, contents: [i for i in contents if i == '__pycache__'])
            print("done", flush=True)
        else:
            print("used existing Python folder", flush=True)

    # And DLLs if we're doing a Windows runtime)
    if copy_dlls and ext == ".exe":
        print("Copying DLLs...", end=" ", flush=True)
        for file in [i for i in os.listdir(blender_dir) if i.lower().endswith('.dll')]:
            src = os.path.join(blender_dir, file)
            dst = os.path.join(output_dir, file)
            shutil.copy2(src, dst)

        print("done", flush=True)

    # Copy assets
    for ap in asset_paths:
        src = bpy.path.abspath(ap.name)
        dst = os.path.join(output_dir, ap.name[2:] if ap.name.startswith('//') else ap.name)

        if os.path.exists(src):
            if os.path.isdir(src):
                if ap.overwrite and os.path.exists(dst):
                    shutil.rmtree(dst)
                elif not os.path.exists(dst):
                    shutil.copytree(src, dst)
            else:
                if ap.overwrite or not os.path.exists(dst):
                    shutil.copy2(src, dst)
        else:
            report({'ERROR'}, "Could not find asset path: '%s'" % src)

    # Make archive
    if make_archive:
        print("Making archive...", end=" ", flush=True)

        arctype = ''
        if player_path.lower().endswith('.exe'):
            arctype = 'zip'
        elif player_path.lower().endswith('.app'):
            arctype = 'zip'
        else: # Linux
            arctype = 'gztar'

        basedir = os.path.normpath(os.path.join(os.path.dirname(output_path), '..'))
        afilename = os.path.join(basedir, os.path.basename(output_dir))

        if arctype == 'gztar':
            # Create the tarball ourselves instead of using shutil.make_archive
            # so we can handle permission bits.

            # The runtimename needs to use forward slashes as a path separator
            # since this is what tarinfo.name is using.
            runtimename = os.path.relpath(output_path, basedir).replace('\\', '/')

            def _set_ex_perm(tarinfo):
                if tarinfo.name == runtimename:
                    tarinfo.mode = 0o755
                return tarinfo

            with tarfile.open(afilename + '.tar.gz', 'w:gz') as tf:
                tf.add(output_dir, os.path.relpath(output_dir, basedir), filter=_set_ex_perm)
        elif arctype == 'zip':
            shutil.make_archive(afilename, 'zip', output_dir)
        else:
            report({'ERROR'}, "Unknown archive type %s for runtime %s\n" % (arctype, player_path))

        print("done", flush=True)


class PublishAllPlatforms(bpy.types.Operator):
    bl_idname = "wm.publish_platforms"
    bl_label = "Exports a runtime for each listed platform"

    def execute(self, context):
        ps = context.scene.ge_publish_settings

        if ps.publish_default_platform:
            print("Publishing default platform")
            blender_bin_path = bpy.app.binary_path
            blender_bin_dir = os.path.dirname(blender_bin_path)
            ext = os.path.splitext(blender_bin_path)[-1].lower()
            WriteRuntime(os.path.join(blender_bin_dir, 'blenderplayer' + ext),
                         os.path.join(ps.output_path, 'default', ps.runtime_name),
                         ps.asset_paths,
                         True,
                         True,
                         True,
                         ps.make_archive,
                         self.report
                         )
        else:
            print("Skipping default platform")

        for platform in ps.platforms:
            if platform.publish:
                print("Publishing", platform.name)
                WriteRuntime(platform.player_path,
                            os.path.join(ps.output_path, platform.name, ps.runtime_name),
                            ps.asset_paths,
                            True,
                            True,
                            True,
                            ps.make_archive,
                            self.report
                            )
            else:
                print("Skipping", platform.name)

        return {'FINISHED'}


class RENDER_PT_publish(bpy.types.Panel):
    bl_label = "Publishing Info"
    bl_space_type = "PROPERTIES"
    bl_region_type = "WINDOW"
    bl_context = "render"

    @classmethod
    def poll(cls, context):
        scene = context.scene
        return scene and (scene.render.engine == "BLENDER_GAME")

    def draw(self, context):
        ps = context.scene.ge_publish_settings
        layout = self.layout

        layout.prop(ps, 'output_path')
        layout.prop(ps, 'runtime_name')
        layout.prop(ps, 'lib_path')
        layout.prop(ps, 'make_archive')

        layout.label("Asset Paths")
        row = layout.row()
        row.template_list("UI_UL_list", "assets_list", ps, 'asset_paths', ps, 'asset_paths_active')
        col = row.column(align=True)
        col.operator(PublishAddAssetPath.bl_idname, icon='ZOOMIN', text="")
        col.operator(PublishRemoveAssetPath.bl_idname, icon='ZOOMOUT', text="")

        if len(ps.asset_paths) > ps.asset_paths_active >= 0:
            ap = ps.asset_paths[ps.asset_paths_active]
            row = layout.row()
            row.prop(ap, 'name')
            row.prop(ap, 'overwrite')

        layout.label("Platforms")
        layout.prop(ps, 'publish_default_platform')
        row = layout.row()
        row.template_list("UI_UL_list", "platforms_list", ps, 'platforms', ps, 'platforms_active')

        col = row.column(align=True)
        col.operator(PublishAddPlatform.bl_idname, icon='ZOOMIN', text="")
        col.operator(PublishRemovePlatform.bl_idname, icon='ZOOMOUT', text="")
        col.menu("PUBLISH_MT_platform_specials", icon='DOWNARROW_HLT', text="")

        if len(ps.platforms) > ps.platforms_active >= 0:
            platform = ps.platforms[ps.platforms_active]
            layout.prop(platform, 'name')
            layout.prop(platform, 'player_path')
            layout.prop(platform, 'publish')

        layout.operator(PublishAllPlatforms.bl_idname, 'Publish Platforms')


class PublishAutoPlatforms(bpy.types.Operator):
    bl_idname = "scene.publish_auto_platforms"
    bl_label = "Auto Add Platforms"

    def execute(self, context):
        ps = context.scene.ge_publish_settings

        lib_path = bpy.path.abspath(ps.lib_path)

        for lib in [i for i in os.listdir(lib_path) if os.path.isdir(os.path.join(lib_path, i))]:
            print("Found folder:", lib)
            player_found = False
            for root, dirs, files in os.walk(os.path.join(lib_path, lib)):
                if "__MACOSX" in root:
                    continue

                for f in dirs + files:
                    if f.startswith("blenderplayer.app") or f.startswith("blenderplayer"):
                        a = ps.platforms.add()
                        if lib.startswith('blender-'):
                            # Clean up names for packages from blender.org
                            # example: blender-2.71-RC2-OSX_10.6-x86_64.zip => OSX_10.6-x86_64.zip
                            # We're pretty consistent on naming, so this should hold up.
                            a.name = '-'.join(lib.split('-')[3 if 'rc' in lib.lower() else 2:])
                        else:
                            a.name = lib
                        a.player_path = bpy.path.relpath(os.path.join(root, f))
                        player_found = True
                        break

                if player_found:
                    break

        return {'FINISHED'}

# TODO This operator takes a long time to run, which is bad for UX. Could this instead be done as some sort of
# modal dialog? This could also allow users to select which platforms to download and give a better progress
# indicator.
class PublishDownloadPlatforms(bpy.types.Operator):
    bl_idname = "scene.publish_download_platforms"
    bl_label = "Download Platforms"

    def execute(self, context):
        import html.parser
        import urllib.request

        remote_platforms = []

        ps = context.scene.ge_publish_settings
        lib_path = bpy.path.abspath(ps.lib_path)

        print("Retrieving list of platforms from blender.org...", end=" ", flush=True)

        class AnchorParser(html.parser.HTMLParser):
            def handle_starttag(self, tag, attrs):
                if tag == 'a':
                    for key, value in attrs:
                        if key == 'href' and value.startswith('blender'):
                            remote_platforms.append(value)

        url = 'http://download.blender.org/release/Blender' + bpy.app.version_string.split()[0]
        parser = AnchorParser()
        data = urllib.request.urlopen(url).read()
        parser.feed(str(data))

        print("done", flush=True)

        print("Downloading files (this will take a while depending on your internet connection speed).", flush=True)
        for i in remote_platforms:
            src = '/'.join((url, i))
            dst = os.path.join(lib_path, i)

            dst_dir = '.'.join([i for i in dst.split('.') if i not in {'zip', 'tar', 'bz2'}])
            if not os.path.exists(dst) and not os.path.exists(dst.split('.')[0]):
                print("Downloading " + src + "...", end=" ", flush=True)
                urllib.request.urlretrieve(src, dst)
                print("done", flush=True)
            else:
                print("Reusing existing file: " + dst, flush=True)

            print("Unpacking " + dst + "...", end=" ", flush=True)
            if os.path.exists(dst_dir):
                shutil.rmtree(dst_dir)
            shutil.unpack_archive(dst, dst_dir)
            print("done", flush=True)

        print("Creating platform from libs...", flush=True)
        bpy.ops.scene.publish_auto_platforms()
        return {'FINISHED'}


class PublishAddPlatform(bpy.types.Operator):
    bl_idname = "scene.publish_add_platform"
    bl_label = "Add Publish Platform"

    def execute(self, context):
        a = context.scene.ge_publish_settings.platforms.add()
        a.name = a.name
        return {'FINISHED'}


class PublishRemovePlatform(bpy.types.Operator):
    bl_idname = "scene.publish_remove_platform"
    bl_label = "Remove Publish Platform"

    def execute(self, context):
        ps = context.scene.ge_publish_settings
        if ps.platforms_active < len(ps.platforms):
            ps.platforms.remove(ps.platforms_active)
            return {'FINISHED'}
        return {'CANCELLED'}


# TODO maybe this should display a file browser?
class PublishAddAssetPath(bpy.types.Operator):
    bl_idname = "scene.publish_add_assetpath"
    bl_label = "Add Asset Path"

    def execute(self, context):
        a = context.scene.ge_publish_settings.asset_paths.add()
        a.name = a.name
        return {'FINISHED'}


class PublishRemoveAssetPath(bpy.types.Operator):
    bl_idname = "scene.publish_remove_assetpath"
    bl_label = "Remove Asset Path"

    def execute(self, context):
        ps = context.scene.ge_publish_settings
        if ps.asset_paths_active < len(ps.asset_paths):
            ps.asset_paths.remove(ps.asset_paths_active)
            return {'FINISHED'}
        return {'CANCELLED'}


class PUBLISH_MT_platform_specials(bpy.types.Menu):
    bl_label = "Platform Specials"

    def draw(self, context):
        layout = self.layout
        layout.operator(PublishAutoPlatforms.bl_idname)
        layout.operator(PublishDownloadPlatforms.bl_idname)


class PlatformSettings(bpy.types.PropertyGroup):
    name = bpy.props.StringProperty(
            name = "Platform Name",
            description = "The name of the platform",
            default = "Platform",
            )

    player_path = bpy.props.StringProperty(
            name = "Player Path",
            description = "The path to the Blenderplayer to use for this platform",
            default = "//lib/platform/blenderplayer",
            subtype = 'FILE_PATH',
            )

    publish = bpy.props.BoolProperty(
            name = "Publish",
            description = "Whether or not to publish to this platform",
            default = True,
            )


class AssetPath(bpy.types.PropertyGroup):
    # TODO This needs a way to be a FILE_PATH or a DIR_PATH
    name = bpy.props.StringProperty(
            name = "Asset Path",
            description = "Path to the asset to be copied",
            default = "//src",
            subtype = 'FILE_PATH',
            )

    overwrite = bpy.props.BoolProperty(
            name = "Overwrite Asset",
            description = "Overwrite the asset if it already exists in the destination folder",
            default = True,
            )


class PublishSettings(bpy.types.PropertyGroup):
    output_path = bpy.props.StringProperty(
            name = "Publish Output",
            description = "Where to publish the game",
            default = "//bin/",
            subtype = 'DIR_PATH',
            )

    runtime_name = bpy.props.StringProperty(
            name = "Runtime name",
            description = "The filename for the created runtime",
            default = "game",
            )

    lib_path = bpy.props.StringProperty(
            name = "Library Path",
            description = "Directory to search for platforms",
            default = "//lib/",
            subtype = 'DIR_PATH',
            )

    publish_default_platform = bpy.props.BoolProperty(
            name = "Publish Default Platform",
            description = "Whether or not to publish the default platform (the Blender install running this addon) when publishing platforms",
            default = True,
            )


    platforms = bpy.props.CollectionProperty(type=PlatformSettings, name="Platforms")
    platforms_active = bpy.props.IntProperty()

    asset_paths = bpy.props.CollectionProperty(type=AssetPath, name="Asset Paths")
    asset_paths_active = bpy.props.IntProperty()

    make_archive = bpy.props.BoolProperty(
            name = "Make Archive",
            description = "Create a zip archive of the published game",
            default = True,
            )


def register():
    bpy.utils.register_module(__name__)

    bpy.types.Scene.ge_publish_settings = bpy.props.PointerProperty(type=PublishSettings)


def unregister():
    bpy.utils.unregister_module(__name__)
    del bpy.types.Scene.ge_publish_settings


if __name__ == "__main__":
    register()
