#    This file is part of Korman.
#
#    Korman 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 3 of the License, or
#    (at your option) any later version.
#
#    Korman 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 Korman.  If not, see <http://www.gnu.org/licenses/>.

import bpy

from collections import defaultdict
from contextlib import ExitStack
import functools
from pathlib import Path

from ..helpers import TemporaryObject
from ..korlib import ConsoleToggler

from PyHSPlasma import *

from . import animation
from . import camera
from . import decal
from . import explosions
from . import etlight
from . import image
from . import locman
from . import logger
from . import manager
from . import mesh
from . import outfile
from . import physics
from . import rtlight
from . import utils

class Exporter:
    def __init__(self, op):
        self._op = op # Blender export operator
        self._objects = []
        self.actors = set()
        self.want_node_trees = defaultdict(set)
        self.exported_nodes = {}

    def run(self):
        log = logger.ExportVerboseLogger if self._op.verbose else logger.ExportProgressLogger
        with ConsoleToggler(self._op.show_console), log(self._op.filepath) as self.report, ExitStack() as self.exit_stack:
            # Step 0: Init export resmgr and stuff
            self.mgr = manager.ExportManager(self)
            self.mesh = mesh.MeshConverter(self)
            self.physics = physics.PhysicsConverter(self)
            self.light = rtlight.LightConverter(self)
            self.animation = animation.AnimationConverter(self)
            self.output = outfile.OutputFiles(self, self._op.filepath)
            self.camera = camera.CameraConverter(self)
            self.image = image.ImageCache(self)
            self.locman = locman.LocalizationConverter(self)
            self.decal = decal.DecalConverter(self)
            self.oven = etlight.LightBaker(mesh=self.mesh, report=self.report)

            # Step 0.8: Init the progress mgr
            self.mesh.add_progress_presteps(self.report)
            self.report.progress_add_step("Collecting Objects")
            self.report.progress_add_step("Verify Competence")
            self.report.progress_add_step("Touching the Intangible")
            self.report.progress_add_step("Unifying Superstrings")
            self.report.progress_add_step("Harvesting Actors")
            if self._op.lighting_method != "skip":
                etlight.LightBaker.add_progress_steps(self.report)
            self.report.progress_add_step("Exporting Scene Objects")
            self.report.progress_add_step("Exporting Logic Nodes")
            self.report.progress_add_step("Finalizing Plasma Logic")
            self.report.progress_add_step("Handling Snakes")
            self.report.progress_add_step("Exporting Textures")
            self.report.progress_add_step("Composing Geometry")
            self.report.progress_add_step("Saving Age Files")
            self.report.progress_start("EXPORTING AGE")

            # Step 0.9: Apply modifiers to all meshes temporarily.
            with self.mesh:
                # Step 1: Create the age info and the pages
                self._export_age_info()

                # Step 2: Gather a list of objects that we need to export, given what the user has told
                #         us to export (both in the Age and Object Properties)... fun
                self._collect_objects()

                # Step 2.1: Run through all the objects we collected in Step 2 and make sure there
                #           is no ruddy funny business going on.
                self._check_sanity()

                # Step 2.2: Run through all the objects again and ask them to "pre_export" themselves.
                #           In other words, generate any ephemeral Blender objects that need to be exported.
                self._pre_export_scene_objects()

                # Step 2.3: Run through all the objects and export localization.
                self._export_localization()

                # Step 2.5: Run through all the objects we collected in Step 2 and see if any relationships
                #           that the artist made requires something to have a CoordinateInterface
                self._harvest_actors()

                # Step 2.9: It is assumed that static lighting is available for the mesh exporter.
                #           Indeed, in PyPRP it was a manual step. So... BAKE NAO!
                self._bake_static_lighting()

                # Step 3: Export all the things!
                self._export_scene_objects()

                # Step 3.1: Ensure referenced logic node trees are exported
                self._export_referenced_node_trees()

                # Step 3.2: Now that all Plasma Objects (save Mipmaps) are exported, we do any post
                #          processing that needs to inspect those objects
                self._post_process_scene_objects()

                # Step 3.3: Ensure any helper Python files are packed
                self._pack_ancillary_python()

                # Step 4: Finalize...
                self.mesh.material.finalize()
                self.mesh.finalize()

                # Step 5: FINALLY. Let's write the PRPs and crap.
                self._save_age()

                # Step 5.1: Save out the export report.
                #           If the export fails and this doesn't save, we have bigger problems than
                #           these little warnings and notices.
                self.report.progress_end()
                self.report.save()

                # Step 5.2: If any nonfatal errors were encountered during the export, we will
                #           raise them here, now that everything is finished, to draw attention
                #           to whatever the problem might be.
                self.report.raise_errors()

    def _bake_static_lighting(self):
        if self._op.lighting_method != "skip":
            self.oven.bake_static_lighting(self._objects)

    def _collect_objects(self):
        scene = bpy.context.scene
        self.report.progress_advance()
        self.report.progress_range = len(scene.objects)
        inc_progress = self.report.progress_increment

        # Grab a naive listing of enabled pages
        age = scene.world.plasma_age
        pages_enabled = frozenset((page.name for page in age.pages if page.enabled and self._op.version in page.version))
        all_pages = frozenset((page.name for page in age.pages))

        # Because we can have an unnamed or a named default page, we need to see if that is enabled...
        for page in age.pages:
            if page.seq_suffix == 0:
                default_enabled = page.enabled
                default_inited = True
                break
        else:
            default_enabled = True
            default_inited = False

        # Now we loop through the objects with some considerations:
        #     - The default page may or may not be defined. If it is, it can be disabled. If not, it
        #       can only ever be enabled.
        #     - Don't create the Default page unless it is used (implicit or explicit). It is a failure
        #       to export a useless file.
        #     - Any arbitrary page can be disabled, so check our frozenset.
        #     - Also, someone might have specified an invalid page, so keep track of that.
        error = explosions.UndefinedPageError()
        for obj in scene.objects:
            if obj.plasma_object.enabled:
                page = obj.plasma_object.page
                if not page and not default_inited:
                    self.mgr.create_page(self.age_name, "Default", 0)
                    default_inited = True

                if (default_enabled and not page) or (page in pages_enabled):
                    self._objects.append(obj)
                elif page not in all_pages:
                    error.add(page, obj.name)
            inc_progress()
        error.raise_if_error()

    def _check_sanity(self):
        self.report.progress_advance()
        self.report.progress_range = len(self._objects)
        inc_progress = self.report.progress_increment

        self.report.msg("\nEnsuring Age is sane...")
        for bl_obj in self._objects:
            for mod in bl_obj.plasma_modifiers.modifiers:
                fn = getattr(mod, "sanity_check", None)
                if fn is not None:
                    fn()
            inc_progress()
        self.report.msg("... Age is grinning and holding a spatula. Must be OK, then.")

    def _export_age_info(self):
        # Make life slightly easier...
        age_info = bpy.context.scene.world.plasma_age
        age_name = self.age_name
        mgr = self.mgr

        # Generate the plAgeInfo
        mgr.AddAge(age_info.export(self))

        # Create all the pages we need
        ver = self._op.version
        for page in age_info.pages:
            if page.enabled and ver in page.version:
                mgr.create_page(age_name, page.name, page.seq_suffix)
        mgr.create_builtins(age_name, age_info.use_texture_page)

    def _export_actor(self, so, bo):
        """Exports a Coordinate Interface if we need one"""
        if self.has_coordiface(bo):
            self._export_coordinate_interface(so, bo)

        # If this object has a parent, then we will need to go upstream and add ourselves to the
        # parent's CoordinateInterface... Because life just has to be backwards.
        parent = bo.parent
        if parent is not None:
            if parent.plasma_object.enabled:
                self.report.msg("Attaching to parent SceneObject '{}'", parent.name, indent=1)
                parent_ci = self._export_coordinate_interface(None, parent)
                parent_ci.addChild(so.key)
            else:
                self.report.warn("You have parented Plasma Object '{}' to '{}', which has not been marked for export. \
                                 The object may not appear in the correct location or animate properly.".format(
                                    bo.name, parent.name))

    def _export_coordinate_interface(self, so, bl):
        """Ensures that the SceneObject has a CoordinateInterface"""
        if so is None:
            so = self.mgr.find_create_object(plSceneObject, bl=bl)
        if so.coord is None:
            ci_cls = bl.plasma_object.ci_type
            ci = self.mgr.add_object(ci_cls, bl=bl, so=so)

            # Now we have the "fun" work of filling in the CI
            ci.localToWorld = utils.matrix44(bl.matrix_basis)
            ci.worldToLocal = ci.localToWorld.inverse()
            ci.localToParent = utils.matrix44(bl.matrix_local)
            ci.parentToLocal = ci.localToParent.inverse()
            return ci
        return so.coord.object

    def _export_localization(self):
        self.report.progress_advance()
        self.report.progress_range = len(self._objects)
        inc_progress = self.report.progress_increment

        self.report.msg("\nExporting localization...")

        for bl_obj in self._objects:
            for mod in filter(lambda x: hasattr(x, "export_localization"), bl_obj.plasma_modifiers.modifiers):
                mod.export_localization(self)
            inc_progress()

    def _export_scene_objects(self):
        self.report.progress_advance()
        self.report.progress_range = len(self._objects)
        inc_progress = self.report.progress_increment
        log_msg = self.report.msg

        for bl_obj in self._objects:
            log_msg("\n[SceneObject '{}']".format(bl_obj.name))

            # First pass: do things specific to this object type.
            #             note the function calls: to export a MESH, it's _export_mesh_blobj
            export_fn = "_export_{}_blobj".format(bl_obj.type.lower())
            try:
                export_fn = getattr(self, export_fn)
            except AttributeError:
                self.report.warn("""'{}' is a Plasma Object of Blender type '{}'
                                 ... And I have NO IDEA what to do with that! Tossing.""".format(bl_obj.name, bl_obj.type))
                continue
            log_msg("Blender Object '{}' of type '{}'".format(bl_obj.name, bl_obj.type), indent=1)

            # Create a sceneobject if one does not exist.
            # Before we call the export_fn, we need to determine if this object is an actor of any
            # sort, and barf out a CI.
            sceneobject = self.mgr.find_create_object(plSceneObject, bl=bl_obj)
            self._export_actor(sceneobject, bl_obj)
            export_fn(sceneobject, bl_obj)

            # And now we puke out the modifiers...
            for mod in bl_obj.plasma_modifiers.modifiers:
                log_msg("Exporting '{}' modifier".format(mod.bl_label), indent=1)
                mod.export(self, bl_obj, sceneobject)
            inc_progress()

    def _export_camera_blobj(self, so, bo):
        # Hey, guess what? Blender's camera data is utter crap!
        camera = bo.data.plasma_camera
        self.camera.export_camera(so, bo, camera.camera_type, camera.settings, camera.transitions)

    def _export_empty_blobj(self, so, bo):
        pass

    def _export_lamp_blobj(self, so, bo):
        self.light.export_rtlight(so, bo)

    def _export_mesh_blobj(self, so, bo):
        if bo.data.materials:
            self.mesh.export_object(bo, so)
        else:
            self.report.msg("No material(s) on the ObData, so no drawables", indent=1)

    def _export_font_blobj(self, so, bo):
        with utils.temporary_mesh_object(bo) as meshObj:
            if bo.data.materials:
                self.mesh.export_object(meshObj, so)
            else:
                self.report.msg("No material(s) on the ObData, so no drawables", indent=1)

    def _export_referenced_node_trees(self):
        self.report.progress_advance()
        self.report.progress_range = len(self.want_node_trees)
        inc_progress = self.report.progress_increment

        self.report.msg("\nChecking Logic Trees...")
        for tree_name, references in self.want_node_trees.items():
            self.report.msg("NodeTree '{}'", tree_name, indent=1)
            tree = bpy.data.node_groups[tree_name]
            for bo, so in references:
                tree.export(self, bo, so)
            inc_progress()

    def _harvest_actors(self):
        self.report.progress_advance()
        self.report.progress_range = len(self._objects) + len(bpy.data.textures)
        inc_progress = self.report.progress_increment

        for bl_obj in self._objects:
            for mod in bl_obj.plasma_modifiers.modifiers:
                if mod.enabled:
                    self.actors.update(mod.harvest_actors())
            inc_progress()

        # This is a little hacky, but it's an edge case... I guess?
        # We MUST have CoordinateInterfaces for EnvironmentMaps (DCMs, bah)
        for texture in bpy.data.textures:
            envmap = getattr(texture, "environment_map", None)
            if envmap is not None:
                viewpt = envmap.viewpoint_object
                if viewpt is not None:
                    self.actors.add(viewpt.name)
            inc_progress()

    def has_coordiface(self, bo):
        if bo.type in {"CAMERA", "EMPTY", "LAMP"}:
            return True
        if bo.parent is not None or bo.children:
            return True
        if bo.name in self.actors:
            return True
        if bo.plasma_object.has_transform_animation:
            return True

        for mod in bo.plasma_modifiers.modifiers:
            if mod.enabled:
                if mod.requires_actor:
                    return True
        return False

    def _post_process_scene_objects(self):
        self.report.progress_advance()
        self.report.progress_range = len(self._objects)
        inc_progress = self.report.progress_increment
        self.report.msg("\nPost-Processing SceneObjects...")

        mat_mgr = self.mesh.material
        for bl_obj in self._objects:
            sceneobject = self.mgr.find_object(plSceneObject, bl=bl_obj)
            if sceneobject is None:
                # no SO? fine then. turd.
                continue

            # Synchronization is applied for the root SO and all animated layers (WTF)
            # So, we have to keep in mind shared layers (whee) in the synch options kode
            net = bl_obj.plasma_net
            net.propagate_synch_options(sceneobject, sceneobject)
            for mat in mat_mgr.get_materials(bl_obj):
                for layer in mat.object.layers:
                    layer = layer.object
                    if isinstance(layer, plLayerAnimation):
                        net.propagate_synch_options(sceneobject, layer)

            # Modifiers don't have to expose post-processing, but if they do, run it
            for mod in bl_obj.plasma_modifiers.modifiers:
                proc = getattr(mod, "post_export", None)
                if proc is not None:
                    self.report.msg("Post processing '{}' modifier '{}'", bl_obj.name, mod.bl_label, indent=1)
                    proc(self, bl_obj, sceneobject)
            inc_progress()

    def _pre_export_scene_objects(self):
        self.report.progress_advance()
        self.report.progress_range = len(self._objects)
        inc_progress = self.report.progress_increment
        self.report.msg("\nGenerating export dependency objects...")

        # New objects may be generate during this process; they will be appended at the end.
        new_objects = []

        @functools.singledispatch
        def handle_temporary(temporary, parent):
            raise RuntimeError("Temporary object of type '{}' generated by '{}' was unhandled".format(temporary.__class__, parent.name))

        @handle_temporary.register(bpy.types.Object)
        def _(temporary, parent):
            self.exit_stack.enter_context(TemporaryObject(temporary, bpy.data.objects.remove))
            self.report.msg("'{}': generated Object '{}' (Plasma Object: {})", parent.name,
                            temporary.name, temporary.plasma_object.enabled, indent=1)
            if temporary.plasma_object.enabled:
                new_objects.append(temporary)

                # If the object is marked as a Plasma Object, be sure that we go into the same page
                # as the requestor, unless the modifier decided it knows better.
                if not temporary.plasma_object.is_property_set("page"):
                    temporary.plasma_object.page = parent.plasma_object.page

                # Wow, recursively generated objects. Aren't you special?
                for mod in temporary.plasma_modifiers.modifiers:
                    mod.sanity_check()
                do_pre_export(temporary)

        @handle_temporary.register(bpy.types.NodeTree)
        def _(temporary, parent):
            self.exit_stack.enter_context(TemporaryObject(temporary, bpy.data.node_groups.remove))
            self.report.msg("'{}' generated NodeTree '{}'", parent.name, temporary.name)
            if temporary.bl_idname == "PlasmaNodeTree":
                parent_so = self.mgr.find_create_object(plSceneObject, bl=parent)
                self.want_node_trees[temporary.name].add((parent, parent_so))

        def do_pre_export(bo):
            for mod in bo.plasma_modifiers.modifiers:
                proc = getattr(mod, "pre_export", None)
                if proc is not None:
                    result = proc(self, bo)
                    if result is not None:
                        for i in filter(None, result):
                            handle_temporary(i, bo)

        for bl_obj in self._objects:
            do_pre_export(bl_obj)
            inc_progress()

        self.report.msg("... {} new object(s) were generated!", len(new_objects))
        self._objects += new_objects

    def _pack_ancillary_python(self):
        texts = bpy.data.texts
        self.report.progress_advance()
        self.report.progress_range = len(texts)
        inc_progress = self.report.progress_increment

        for i in texts:
            if i.name.endswith(".py") and self.output.want_py_text(i):
                self.output.add_python_code(i.name, text_id=i)
            inc_progress()

    def _save_age(self):
        self.report.progress_advance()
        self.report.msg("\nWriting Age data...")

        # If something bad happens in the final flush, it would be a shame to
        # simply toss away the potentially freshly regenerated texture cache.
        try:
            self.locman.save()
            self.mgr.save_age()
            self.output.save()
        finally:
            self.image.save()

    @property
    def age_name(self):
        if self._op.dat_only:
            return Path(self._op.filepath).stem
        else:
            return bpy.context.scene.world.plasma_age.age_name

    @property
    def dat_only(self):
        return self._op.dat_only

    @property
    def envmap_method(self):
        return bpy.context.scene.world.plasma_age.envmap_method

    @property
    def python_method(self):
        return bpy.context.scene.world.plasma_age.python_method

    @property
    def texcache_path(self):
        age = bpy.context.scene.world.plasma_age
        filepath = age.texcache_path
        try:
            valid_path = filepath and Path(filepath).is_file()
        except OSError:
            valid_path = False

        if not valid_path:
            filepath = bpy.context.blend_data.filepath
            if not filepath:
                filepath = self._op.filepath
            filepath = str(Path(filepath).with_suffix(".ktc"))
            age.texcache_path = filepath
        return filepath

    @property
    def texcache_method(self):
        return bpy.context.scene.world.plasma_age.texcache_method
