#    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
import functools
import itertools
import math
import mathutils
from typing import *
import weakref

from PyHSPlasma import *

from . import utils

class AnimationConverter:
    def __init__(self, exporter):
        self._exporter = weakref.ref(exporter)
        self._bl_fps = bpy.context.scene.render.fps

    def convert_frame_time(self, frame_num: int) -> float:
        return frame_num / self._bl_fps

    def convert_object_animations(self, bo: bpy.types.Object, so: plSceneObject, anim_name: str, *,
                                  start: Optional[int] = None, end: Optional[int] = None) -> Iterable[plAGApplicator]:
        if not bo.plasma_object.has_animation_data:
            return []

        def fetch_animation_data(id_data):
            if id_data is not None:
                if id_data.animation_data is not None:
                    action = id_data.animation_data.action
                    return action, getattr(action, "fcurves", [])
            return None, []

        obj_action, obj_fcurves = fetch_animation_data(bo)
        data_action, data_fcurves = fetch_animation_data(bo.data)

        # We're basically just going to throw all the FCurves at the controller converter (read: wall)
        # and see what sticks. PlasmaMAX has some nice animation channel stuff that allows for some
        # form of separation, but Blender's NLA editor is way confusing and appears to not work with
        # things that aren't the typical position, rotation, scale animations.
        applicators = []
        if isinstance(bo.data, bpy.types.Camera):
            applicators.append(self._convert_camera_animation(bo, so, obj_fcurves, data_fcurves, anim_name, start, end))
        else:
            applicators.append(self._convert_transform_animation(bo, obj_fcurves, bo.matrix_basis, start=start, end=end))
        if bo.plasma_modifiers.soundemit.enabled:
            applicators.extend(self._convert_sound_volume_animation(bo.name, obj_fcurves, bo.plasma_modifiers.soundemit, start, end))
        if isinstance(bo.data, bpy.types.Lamp):
            lamp = bo.data
            applicators.extend(self._convert_lamp_color_animation(bo.name, data_fcurves, lamp, start, end))
            if isinstance(lamp, bpy.types.SpotLamp):
                applicators.extend(self._convert_spot_lamp_animation(bo.name, data_fcurves, lamp, start, end))
            if isinstance(lamp, bpy.types.PointLamp):
                applicators.extend(self._convert_omni_lamp_animation(bo.name, data_fcurves, lamp, start, end))

        return [i for i in applicators if i is not None]

    def _convert_camera_animation(self, bo, so, obj_fcurves, data_fcurves, anim_name: str,
                                  start: Optional[int], end: Optional[int]):
        has_fov_anim = False
        if data_fcurves:
            # The hard part about this crap is that FOV animations are not stored in ATC Animations
            # instead, FOV animation keyframes are held inside of the camera modifier. Cyan's solution
            # in PlasmaMAX appears to be for any xform keyframe, add two messages to the camera modifier
            # representing the FOV at that point. Makes more sense to me to use each FOV keyframe instead
            fov_fcurve = next((i for i in data_fcurves if i.data_path == "plasma_camera.settings.fov"), None)
            if fov_fcurve:
                # NOTE: this is another critically important key ordering in the SceneObject modifier
                #       list. CameraModifier calls into AGMasterMod code that assumes the AGModifier
                #       is already available. Should probably consider adding some code to libHSPlasma
                #       to order the SceneObject modifier key vector at some point.
                anim_key = self.get_animation_key(bo)
                camera = self._mgr.find_create_object(plCameraModifier, so=so)
                cam_key = camera.key
                aspect, fps = (3.0 / 4.0), self._bl_fps
                degrees = math.degrees
                fov_fcurve.update()


                # Well, now that we have multiple animations, we are using our fancier FCurve processing.
                # Unfortunately, the code still looks like sin. What can you do?
                keyframes, _ = self._process_fcurve(fov_fcurve, start=start, end=end)
                num_keyframes = len(keyframes)

                has_fov_anim = bool(num_keyframes)
                i = 0
                while i < num_keyframes:
                    # So remember, these are messages. When we hit a keyframe, we're dispatching a message
                    # representing the NEXT desired FOV.
                    this_keyframe = keyframes[i]
                    next_keyframe = keyframes[0] if i+1 == num_keyframes else keyframes[i+1]

                    # This message is held on the camera modifier and sent to the animation... It calls
                    # back when the animation reaches the keyframe time, causing the FOV message to be sent.
                    # This should be exported per-animation because it will be specific to each ATC.
                    cb_msg = plEventCallbackMsg()
                    cb_msg.event = kTime
                    cb_msg.eventTime = this_keyframe.frame_time
                    cb_msg.index = i
                    cb_msg.repeats = -1
                    cb_msg.addReceiver(cam_key)
                    anim_msg = plAnimCmdMsg()
                    anim_msg.animName = anim_name
                    anim_msg.time = this_keyframe.frame_time
                    anim_msg.sender = anim_key
                    anim_msg.addReceiver(anim_key)
                    anim_msg.addCallback(cb_msg)
                    anim_msg.setCmd(plAnimCmdMsg.kAddCallbacks, True)
                    camera.addMessage(anim_msg, anim_key)

                    # This is the message actually changes the FOV. Interestingly, it is sent at
                    # export-time and while playing the game, the camera modifier just steals its
                    # parameters and passes them to the brain. Can't make this stuff up.
                    # Be sure to only export each instruction once.
                    if not any((msg.config.accel == next_keyframe.frame_time for msg in camera.fovInstructions)):
                        cam_msg = plCameraMsg()
                        cam_msg.addReceiver(cam_key)
                        cam_msg.setCmd(plCameraMsg.kAddFOVKeyFrame, True)
                        cam_config = cam_msg.config
                        cam_config.accel = next_keyframe.frame_time # Yassss...
                        cam_config.fovW = degrees(next_keyframe.values[0])
                        cam_config.fovH = degrees(next_keyframe.values[0] * aspect)
                        camera.addFOVInstruction(cam_msg)

                    i += 1

        # If we exported any FOV animation at all, then we need to ensure there is an applicator
        # returned from here... At bare minimum, we'll need the applicator with an empty
        # CompoundController. This should be sufficient to keep CWE from crashing...
        applicator = self._convert_transform_animation(bo, obj_fcurves, bo.matrix_basis,
                                                       allow_empty=has_fov_anim, start=start, end=end)
        camera = self._mgr.find_create_object(plCameraModifier, so=so)
        camera.animated = applicator is not None
        return applicator

    def _convert_lamp_color_animation(self, name, fcurves, lamp, start, end):
        if not fcurves:
            return None

        energy_curve = next((i for i in fcurves if i.data_path == "energy" and i.keyframe_points), None)
        color_curves = [i for i in fcurves if i.data_path == "color" and i.keyframe_points]
        if energy_curve is None and color_curves is None:
            return None
        elif lamp.use_only_shadow:
            self._exporter().report.warn("Cannot animate Lamp color because this lamp only casts shadows", indent=3)
            return None
        elif not lamp.use_specular and not lamp.use_diffuse:
            self._exporter().report.warn("Cannot animate Lamp color because neither Diffuse nor Specular are enabled", indent=3)
            return None

        # OK Specular is easy. We just toss out the color as a point3.
        def convert_specular_animation(color):
            if lamp.use_negative:
                return map(lambda x: x * -1.0, color)
            else:
                return color
        color_keyframes, color_bez = self._process_keyframes(color_curves, 3, lamp.color,
                                                             convert=convert_specular_animation,
                                                             start=start, end=end)
        if color_keyframes and lamp.use_specular:
            channel = plPointControllerChannel()
            channel.controller = self._make_point3_controller(color_keyframes, color_bez)
            applicator = plLightSpecularApplicator()
            applicator.channelName = name
            applicator.channel = channel
            yield applicator

        # Hey, look, it's a third way to process FCurves. YAY!
        def convert_diffuse_animation(color, energy):
            if lamp.use_negative:
                proc = lambda x: x * -1.0 * energy[0]
            else:
                proc = lambda x: x * energy[0]
            return map(proc, color)
        diffuse_channels = dict(color=3, energy=1)
        diffuse_defaults = dict(color=lamp.color, energy=lamp.energy)
        diffuse_fcurves = color_curves + [energy_curve,]
        diffuse_keyframes = self._process_fcurves(diffuse_fcurves, diffuse_channels, 3,
                                                  convert_diffuse_animation, diffuse_defaults,
                                                  start=start, end=end)
        if not diffuse_keyframes:
            return None

        # Whew.
        channel = plPointControllerChannel()
        channel.controller = self._make_point3_controller(diffuse_keyframes, False)
        applicator = plLightDiffuseApplicator()
        applicator.channelName = name
        applicator.channel = channel
        yield applicator

    def _convert_omni_lamp_animation(self, name, fcurves, lamp, start, end):
        if not fcurves:
            return None

        energy_fcurve = next((i for i in fcurves if i.data_path == "energy"), None)
        distance_fcurve = next((i for i in fcurves if i.data_path == "distance"), None)
        if energy_fcurve is None and distance_fcurve is None:
            return None

        light_converter, report = self._exporter().light, self._exporter().report
        omni_fcurves = [distance_fcurve, energy_fcurve]
        omni_channels = dict(distance=1, energy=1)
        omni_defaults = dict(distance=lamp.distance, energy=lamp.energy)

        def convert_omni_atten(distance, energy):
            intens = abs(energy[0])
            atten_end = distance[0] if lamp.use_sphere else distance[0] * 2
            return light_converter.convert_attenuation_linear(intens, atten_end)

        # All types allow animating cutoff
        if distance_fcurve is not None:
            channel = plScalarControllerChannel()
            channel.controller = self.make_scalar_leaf_controller(distance_fcurve,
                                                                  lambda x: x if lamp.use_sphere else x * 2,
                                                                  start=start, end=end)
            applicator = plOmniCutoffApplicator()
            applicator.channelName = name
            applicator.channel = channel
            yield applicator

        falloff = lamp.falloff_type
        if falloff == "CONSTANT":
            if energy_fcurve is not None:
                report.warn("Constant attenuation cannot be animated in Plasma", ident=3)
        elif falloff == "INVERSE_LINEAR":
            keyframes = self._process_fcurves(omni_fcurves, omni_channels, 1, convert_omni_atten,
                                              omni_defaults, start=start, end=end)
            if keyframes:
                channel = plScalarControllerChannel()
                channel.controller = self._make_scalar_leaf_controller(keyframes, False)
                applicator = plOmniApplicator()
                applicator.channelName = name
                applicator.channel = channel
                yield applicator
        elif falloff == "INVERSE_SQUARE":
            if self._mgr.getVer() >= pvMoul:
                report.port("Lamp {} Falloff animations are only supported in Myst Online: Uru Live", falloff, indent=3)
                keyframes = self._process_fcurves(omni_fcurves, omni_channels, 1, convert_omni_atten,
                                                  omni_defaults, start=start, end=end)
                if keyframes:
                    channel = plScalarControllerChannel()
                    channel.controller = self._make_scalar_leaf_controller(keyframes, False)
                    applicator = plOmniSqApplicator()
                    applicator.channelName = name
                    applicator.channel = channel
                    yield applicator
            else:
                report.warn("Lamp {} Falloff animations are not supported for this version of Plasma", falloff, indent=3)
        else:
            report.warn("Lamp Falloff '{}' animations are not supported", falloff, ident=3)

    def _convert_sound_volume_animation(self, name, fcurves, soundemit, start, end):
        if not fcurves:
            return None

        convert_volume = lambda x: math.log10(max(.01, x / 100.0)) * 20.0

        for sound in soundemit.sounds:
            path = "{}.volume".format(sound.path_from_id())
            fcurve = next((i for i in fcurves if i.data_path == path and i.keyframe_points), None)
            if fcurve is None:
                continue

            for i in soundemit.get_sound_indices(sound=sound):
                applicator = plSoundVolumeApplicator()
                applicator.channelName = name
                applicator.index = i

                # libHSPlasma assumes a channel is not shared among applicators...
                # so yes, we must convert the same animation data again and again.
                # To make matters worse, the way that these keyframes are stored can cause
                # the animation to evaluate to a no-op. Be ready for that.
                controller = self.make_scalar_leaf_controller(fcurve, convert=convert_volume, start=start, end=end)
                if controller is not None:
                    channel = plScalarControllerChannel()
                    channel.controller = controller
                    applicator.channel = channel
                    yield applicator
                else:
                    self._exporter().report.warn("[{}]: Volume animation evaluated to zero keyframes!",
                                                 sound.sound.name, indent=2)
                    break

    def _convert_spot_lamp_animation(self, name, fcurves, lamp, start, end):
        if not fcurves:
            return None

        blend_fcurve = next((i for i in fcurves if i.data_path == "spot_blend"), None)
        size_fcurve = next((i for i in fcurves if i.data_path == "spot_size"), None)
        if blend_fcurve is None and size_fcurve is None:
            return None

        # Spot Outer is just the size keyframes...
        if size_fcurve is not None:
            channel = plScalarControllerChannel()
            channel.controller = self.make_scalar_leaf_controller(size_fcurve, lambda x: math.degrees(x),
                                                                  start=start, end=end)
            applicator = plSpotOuterApplicator()
            applicator.channelName = name
            applicator.channel = channel
            yield applicator

        # Spot inner must be calculated...
        def convert_spot_inner(spot_blend, spot_size):
            blend = min(0.001, spot_blend[0])
            size = spot_size[0]
            value = size - (blend * size)
            return math.degrees(value)

        inner_fcurves = [blend_fcurve, size_fcurve]
        inner_channels = dict(spot_blend=1, spot_size=1)
        inner_defaults = dict(spot_blend=lamp.spot_blend, spot_size=lamp.spot_size)
        keyframes = self._process_fcurves(inner_fcurves, inner_channels, 1, convert_spot_inner,
                                          inner_defaults, start=start, end=end)

        if keyframes:
            channel = plScalarControllerChannel()
            channel.controller = self._make_scalar_leaf_controller(keyframes, False)
            applicator = plSpotInnerApplicator()
            applicator.channelName = name
            applicator.channel = channel
            yield applicator

    def _convert_transform_animation(self, bo, fcurves, xform, *, allow_empty: Optional[bool] = False,
                                     start: Optional[int] = None, end: Optional[int] = None) -> Optional[plMatrixChannelApplicator]:
        tm = self.convert_transform_controller(fcurves, bo.rotation_mode, xform, allow_empty=allow_empty,
                                               start=start, end=end)
        if tm is None and not allow_empty:
            return None

        applicator = plMatrixChannelApplicator()
        applicator.enabled = True
        applicator.channelName = bo.name
        channel = plMatrixControllerChannel()
        channel.controller = tm
        applicator.channel = channel
        channel.affine = utils.affine_parts(xform)

        return applicator

    def convert_transform_controller(self, fcurves, rotation_mode: str, xform, *,
                                     allow_empty: Optional[bool] = False,
                                     start: Optional[int] = None,
                                     end: Optional[int] = None) -> Union[None, plCompoundController]:
        if not fcurves and not allow_empty:
            return None

        pos = self.make_pos_controller(fcurves, "location", xform.to_translation(), start=start, end=end)
        rot = self.make_rot_controller(fcurves, rotation_mode, xform, start=start, end=end)
        scale = self.make_scale_controller(fcurves, "scale", xform.to_scale(), start=start, end=end)
        if pos is None and rot is None and scale is None:
            if not allow_empty:
                return None

        tm = plCompoundController()
        tm.X = pos
        tm.Y = rot
        tm.Z = scale
        return tm

    def get_anigraph_keys(self, bo=None, so=None) -> Tuple[plKey, plKey]:
        mod = self._mgr.find_create_key(plAGModifier, so=so, bl=bo)
        master = self._mgr.find_create_key(plAGMasterMod, so=so, bl=bo)
        return mod, master

    def get_anigraph_objects(self, bo=None, so=None) -> Tuple[plAGModifier, plAGMasterMod]:
        mod = self._mgr.find_create_object(plAGModifier, so=so, bl=bo)
        master = self._mgr.find_create_object(plAGMasterMod, so=so, bl=bo)
        return mod, master

    def get_animation_key(self, bo, so=None) -> plKey:
        # we might be controlling more than one animation. isn't that cute?
        # https://www.youtube.com/watch?v=hspNaoxzNbs
        # (but obviously this is not wrong...)
        group_mod = bo.plasma_modifiers.animation_group
        if group_mod.enabled:
            return self._mgr.find_create_key(plMsgForwarder, bl=bo, so=so, name=group_mod.key_name)
        else:
            return self.get_anigraph_keys(bo, so)[1]

    def get_frame_time_range(self, *anims: Iterable[Union[plAGApplicator, plController]],
                             so: Optional[plSceneObject] = None, name: Optional[str] = None) -> Tuple[int, int]:
        """Determines the range of frame numbers in an exported animation."""
        def iter_frame_times():
            nonlocal name
            for anim in anims:
                if isinstance(anim, plAGApplicator):
                    anim = anim.channel.controller
                if anim is None:
                    # Maybe a camera FOV thing, or something.
                    continue

                def iter_leaves(ctrl: Optional[plController]) -> Iterator[plLeafController]:
                    if ctrl is None:
                        return
                    elif isinstance(ctrl, plCompoundController):
                        yield from iter_leaves(ctrl.X)
                        yield from iter_leaves(ctrl.Y)
                        yield from iter_leaves(ctrl.Z)
                    elif isinstance(ctrl, plLeafController):
                        yield ctrl
                    else:
                        raise ValueError(ctrl)

                yield from (key.frameTime for leaf in iter_leaves(anim) for key in leaf.keys[0])

                # Special case: camera animations are over on the plCameraModifier. Grr.
                if so is not None:
                    camera = self._mgr.find_object(plCameraModifier, so=so)
                    if camera is not None:
                        if not name:
                            name = "(Entire Animation)"
                        yield from (msg.time for msg, _ in camera.messageQueue if isinstance(msg, plAnimCmdMsg) and msg.animName == name)

        try:
            return min(iter_frame_times()), max(iter_frame_times())
        except ValueError:
            return 0.0, 0.0

    def make_matrix44_controller(self, fcurves, pos_path: str, scale_path: str, pos_default, scale_default,
                                 *, start: Optional[int] = None, end: Optional[int] = None) -> Optional[plLeafController]:
        def convert_matrix_keyframe(**kwargs) -> hsMatrix44:
            pos = kwargs[pos_path]
            scale = kwargs[scale_path]

            translation = hsVector3(pos[0] - (scale[0] - 1.0) / 2.0,
                                    -pos[1] - (scale[1] - 1.0) / 2.0,
                                    pos[2] - (scale[2] - 1.0) / 2.0)
            matrix = hsMatrix44()
            matrix.setTranslate(translation)
            matrix.setScale(hsVector3(*scale))
            return matrix

        fcurves = [i for i in fcurves if i.data_path == pos_path or i.data_path == scale_path]
        if not fcurves:
            return None

        channels = { pos_path: 3, scale_path: 3 }
        default_values = { pos_path: pos_default, scale_path: scale_default }
        keyframes = self._process_fcurves(fcurves, channels, 1, convert_matrix_keyframe,
                                          default_values, start=start, end=end)
        if not keyframes:
            return None

        # Now we make the controller
        return self._make_matrix44_controller(keyframes)

    def make_pos_controller(self, fcurves, data_path: str, default_xform,
                            convert: Optional[Callable] = None, *, start: Optional[int] = None,
                            end: Optional[int] = None) -> Optional[plLeafController]:
        pos_curves = [i for i in fcurves if i.data_path == data_path and i.keyframe_points]
        keyframes, bez_chans = self._process_keyframes(pos_curves, 3, default_xform, convert,
                                                       start=start, end=end)
        if not keyframes:
            return None

        # At one point, I had some... insanity here to try to crush bezier channels and hand off to
        # blah blah blah... As it turns out, point3 keyframe's tangents are vector3s :)
        ctrl = self._make_point3_controller(keyframes, bez_chans)
        return ctrl

    def make_rot_controller(self, fcurves, rotation_mode: str, default_xform,
                            convert: Optional[Callable] = None, *, start: Optional[int] = None,
                            end: Optional[int] = None) -> Union[None, plCompoundController, plLeafController]:
        if rotation_mode in {"AXIS_ANGLE", "QUATERNION"}:
            rot_curves = [i for i in fcurves if i.data_path == "rotation_{}".format(rotation_mode.lower()) and i.keyframe_points]
            if not rot_curves:
                return None

            default_xform = default_xform.to_quaternion()
            if rotation_mode == "AXIS_ANGLE":
                default_xform = default_xform.to_axis_angle()
                default_xform = (default_xform[1], default_xform[0].x, default_xform[0].y, default_xform[0].z)

                if convert is not None:
                    convert = lambda x: convert(mathutils.Quaternion(x[1:4], x[0]))[:]
                else:
                    convert = lambda x: mathutils.Quaternion(x[1:4], x[0])[:]

            # Just dropping bezier stuff on the floor because Plasma does not support it, and
            # I think that opting into quaternion keyframes is a good enough indication that
            # you're OK with that.
            keyframes, bez_chans = self._process_keyframes(rot_curves, 4, default_xform, convert,
                                                           start=start, end=end)
            if keyframes:
                return self._make_quat_controller(keyframes)
        else:
            rot_curves = [i for i in fcurves if i.data_path == "rotation_euler" and i.keyframe_points]
            if not rot_curves:
                return None

            # OK, so life is complicated with Euler keyframes because apparently they can store
            # different "orders" that really only become apparent when the engine converts them
            # into a quaternion to use in an animation. Converting orders isn't as simple as swapping
            # XYZ around, so we have to bus this through quaternion??? Ugh.
            def convert_euler_keyframe(euler_array: Tuple[float, float, float]):
                euler = mathutils.Euler(euler_array, rotation_mode)
                result = euler.to_quaternion().to_euler("XYZ")
                if convert is not None:
                    result = convert(result)
                return result[:]

            euler_convert = convert_euler_keyframe if rotation_mode != "XYZ" else convert
            keyframes, bez_chans = self._process_keyframes(rot_curves, 3, default_xform.to_euler(rotation_mode),
                                                           euler_convert, start=start, end=end)
            if keyframes:
                # Once again, quaternion keyframes do not support bezier interpolation. Ideally,
                # we would just drop support for rotation beziers entirely to simplify all this
                # Euler crap, but some artists may require bezier interpolation...
                if bez_chans:
                    return self._make_scalar_compound_controller(keyframes, bez_chans)
                else:
                    return self._make_quat_controller(keyframes)

    def make_scale_controller(self, fcurves, data_path: str, default_xform,
                              convert: Optional[Callable] = None, *, start: Optional[int] = None,
                              end: Optional[int] = None) -> Optional[plLeafController]:
        scale_curves = [i for i in fcurves if i.data_path == data_path and i.keyframe_points]
        keyframes, bez_chans = self._process_keyframes(scale_curves, 3, default_xform, convert,
                                                       start=start, end=end)
        if not keyframes:
            return None

        # There is no such thing as a compound scale controller... in Plasma, anyway.
        ctrl = self._make_scale_value_controller(keyframes, bez_chans)
        return ctrl

    def make_scalar_leaf_controller(self, fcurve: bpy.types.FCurve,
                                    convert: Optional[Callable] = None, *,
                                    start: Optional[int] = None,
                                    end: Optional[int] = None) -> Optional[plLeafController]:
        keyframes, bezier = self._process_fcurve(fcurve, convert, start=start, end=end)
        if not keyframes:
            return None

        ctrl = self._make_scalar_leaf_controller(keyframes, bezier)
        return ctrl

    def _make_matrix44_controller(self, keyframes) -> plLeafController:
        ctrl = plLeafController()
        keyframe_type = hsKeyFrame.kMatrix44KeyFrame
        exported_frames = []

        for keyframe in keyframes:
            exported = hsMatrix44Key()
            exported.frame = keyframe.frame_num
            exported.frameTime = keyframe.frame_time
            exported.type = keyframe_type
            exported.value = keyframe.values[0]
            exported_frames.append(exported)
        ctrl.keys = (exported_frames, keyframe_type)
        return ctrl

    def _make_point3_controller(self, keyframes, bezier) -> plLeafController:
        ctrl = plLeafController()
        keyframe_type = hsKeyFrame.kBezPoint3KeyFrame if bezier else hsKeyFrame.kPoint3KeyFrame
        exported_frames = []

        for keyframe in keyframes:
            exported = hsPoint3Key()
            exported.frame = keyframe.frame_num
            exported.frameTime = keyframe.frame_time
            exported.type = keyframe_type
            exported.inTan = hsVector3(*keyframe.in_tans)
            exported.outTan = hsVector3(*keyframe.out_tans)
            exported.value = hsVector3(*keyframe.values)
            exported_frames.append(exported)
        ctrl.keys = (exported_frames, keyframe_type)
        return ctrl

    def _make_quat_controller(self, keyframes) -> plLeafController:
        ctrl = plLeafController()
        keyframe_type = hsKeyFrame.kQuatKeyFrame
        exported_frames = []

        for keyframe in keyframes:
            exported = hsQuatKey()
            exported.frame = keyframe.frame_num
            exported.frameTime = keyframe.frame_time
            exported.type = keyframe_type
            # NOTE: quat keyframes don't do bezier nonsense

            values = keyframe.values
            num_channels = len(values)
            if num_channels == 3:
                value = mathutils.Euler(values)
                exported.value = utils.quaternion(value.to_quaternion())
            elif num_channels == 4:
                # Blender orders its quats WXYZ (nonstandard) but Plasma uses XYZW (standard)
                # Also note that manual incoming quat data might be goofy, so renormalize
                value = mathutils.Quaternion(values)
                value.normalize()
                exported.value = utils.quaternion(value)
            else:
                raise ValueError("Unexpected number of channels in quaternion keyframe {}".format(num_channels))
            exported_frames.append(exported)
        ctrl.keys = (exported_frames, keyframe_type)
        return ctrl

    def _make_scalar_compound_controller(self, keyframes, bez_chans) -> plCompoundController:
        ctrl = plCompoundController()
        subctrls = ("X", "Y", "Z")
        for i in subctrls:
            setattr(ctrl, i, plLeafController())
        exported_frames = ([], [], [])

        for keyframe in keyframes:
            for i, subctrl in enumerate(subctrls):
                keyframe_type = hsKeyFrame.kBezScalarKeyFrame if i in bez_chans else hsKeyFrame.kScalarKeyFrame
                exported = hsScalarKey()
                exported.frame = keyframe.frame_num
                exported.frameTime = keyframe.frame_time
                exported.inTan = keyframe.in_tans[i]
                exported.outTan = keyframe.out_tans[i]
                exported.type = keyframe_type
                exported.value = keyframe.values[i]
                exported_frames[i].append(exported)
        for i, subctrl in enumerate(subctrls):
            my_keyframes = exported_frames[i]
            getattr(ctrl, subctrl).keys = (my_keyframes, my_keyframes[0].type)
        return ctrl

    def _make_scalar_leaf_controller(self, keyframes, bezier) -> plLeafController:
        ctrl = plLeafController()
        keyframe_type = hsKeyFrame.kBezScalarKeyFrame if bezier else hsKeyFrame.kScalarKeyFrame
        exported_frames = []

        for keyframe in keyframes:
            exported = hsScalarKey()
            exported.frame = keyframe.frame_num
            exported.frameTime = keyframe.frame_time
            exported.inTan = keyframe.in_tans[0]
            exported.outTan = keyframe.out_tans[0]
            exported.type = keyframe_type
            exported.value = keyframe.values[0]
            exported_frames.append(exported)
        ctrl.keys = (exported_frames, keyframe_type)
        return ctrl

    def _make_scale_value_controller(self, keyframes, bez_chans) -> plLeafController:
        keyframe_type = hsKeyFrame.kBezScaleKeyFrame if bez_chans else hsKeyFrame.kScaleKeyFrame
        exported_frames = []

        # Hmm... This smells... But it was basically doing this before the rewrite.
        unit_quat = hsQuat(0.0, 0.0, 0.0, 1.0)

        for keyframe in keyframes:
            exported = hsScaleKey()
            exported.frame = keyframe.frame_num
            exported.frameTime = keyframe.frame_time
            exported.type = keyframe_type
            exported.inTan = hsVector3(*keyframe.in_tans)
            exported.outTan = hsVector3(*keyframe.out_tans)
            exported.value = (hsVector3(*keyframe.values), unit_quat)
            exported_frames.append(exported)

        ctrl = plLeafController()
        ctrl.keys = (exported_frames, keyframe_type)
        return ctrl

    def _sort_and_dedupe_keyframes(self, keyframes: Dict) -> Sequence:
        """Takes in the final, unsorted keyframe sequence and sorts it. If all keyframes are
           equivalent, eg due to a convert function, then they are discarded."""

        num_keyframes = len(keyframes)
        keyframes_sorted = [keyframes[i] for i in sorted(keyframes)]

        # If any keyframe's value is equivalent to its boundary keyframes, discard it.
        def filter_boundaries(i):
            if i == 0 or i == num_keyframes - 1:
                return False
            left, me, right = keyframes_sorted[i - 1], keyframes_sorted[i], keyframes_sorted[i + 1]
            return left.values == me.values == right.values

        filtered_indices = list(itertools.filterfalse(filter_boundaries, range(num_keyframes)))
        if len(filtered_indices) == 2:
            if keyframes_sorted[filtered_indices[0]].values == keyframes_sorted[filtered_indices[1]].values:
                return []
        return [keyframes_sorted[i] for i in filtered_indices]

    def _process_fcurve(self, fcurve: bpy.types.FCurve, convert: Optional[Callable] = None, *,
                        start: Optional[int] = None, end: Optional[int] = None) -> Tuple[Sequence, AbstractSet]:
        """Like _process_keyframes, but for one fcurve"""

        # Adapt from incoming single item sequence to a single argument.
        if convert is not None:
            single_convert = lambda x: convert(x[0])
        else:
            single_convert = None
        # Can't proxy to _process_fcurves because it only supports linear interoplation.
        return self._process_keyframes([fcurve], 1, [0.0], single_convert, start=start, end=end)

    def _santize_converted_values(self, num_channels: int, raw_values: Union[Dict, Sequence], convert: Callable):
        assert convert is not None
        if isinstance(raw_values, Dict):
            values = convert(**raw_values)
        elif isinstance(raw_values, Sequence):
            values = convert(raw_values)
        else:
            raise AssertionError("Unexpected type for raw_values: {}".format(raw_values.__class__))

        if not isinstance(values, Sequence) and isinstance(values, Iterable):
            values = tuple(values)
        if not isinstance(values, Sequence):
            assert num_channels == 1, "Converter returned 1 value but expected {}".format(num_channels)
            values = (values,)
        else:
            assert len(values) == num_channels, "Converter returned {} values but expected {}".format(len(values), num_channels)
        return values

    def _process_fcurves(self, fcurves: Sequence, channels: Dict[str, int], result_channels: int,
                         convert: Callable, defaults: Dict[str, Union[float, Sequence]], *,
                         start: Optional[int] = None, end: Optional[int] = None) -> Sequence:
        """This consumes a sequence of Blender FCurves that map to a single Plasma controller.
           Like `_process_keyframes()`, except the converter function is mandatory, and each
           Blender `data_path` must have a fixed number of channels.
        """

        # TODO: This fxn should probably issue a warning if any keyframes use bezier interpolation.
        # But there's no indication given by any other fxn when an invalid interpolation mode is
        # given, so what can you do?
        keyframe_data = type("KeyFrameData", (), {})
        fps, pi = self._bl_fps, math.pi

        grouped_fcurves = defaultdict(dict)
        for fcurve in (i for i in fcurves if i is not None):
            fcurve.update()
            grouped_fcurves[fcurve.data_path][fcurve.array_index] = fcurve

        if start is not None and end is not None:
            framenum_filter = lambda x: x.co[0] >= start and x.co[0] <= end
        elif start is not None and end is None:
            framenum_filter = lambda x: x.co[0] >= start
        elif start is None and end is not None:
            framenum_filter = lambda x: x.co[0] <= end
        else:
            framenum_filter = lambda x: True

        fcurve_keyframes = defaultdict(lambda: defaultdict(dict))
        for fcurve in (i for i in fcurves if i is not None):
            for fkey in filter(framenum_filter, fcurve.keyframe_points):
                fcurve_keyframes[fkey.co[0]][fcurve.data_path][fcurve.array_index] = fkey

        def iter_channel_values(frame_num : int, fcurves : Dict, fkeys : Dict, num_channels : int, defaults : Union[float, Sequence]):
            for i in range(num_channels):
                fkey = fkeys.get(i, None)
                if fkey is None:
                    fcurve = fcurves.get(i, None)
                    if fcurve is None:
                        # We would like to test this to see if it makes sense, but Blender's mathutils
                        # types don't actually implement the sequence protocol. So, we'll have to
                        # just try to subscript it and see what happens.
                        try:
                            yield defaults[i]
                        except:
                            assert num_channels == 1, "Got a non-subscriptable default for a multi-channel keyframe."
                            yield defaults
                    else:
                        yield fcurve.evaluate(frame_num)
                else:
                    yield fkey.co[1]

        keyframes = {}
        for frame_num, fkeys in fcurve_keyframes.items():
            keyframe = keyframe_data()
            # hope you don't have a frame 29.9 and frame 30.0...
            keyframe.frame_num = int(frame_num * (30.0 / fps))
            keyframe.frame_num_blender = frame_num
            keyframe.frame_time = frame_num / fps
            keyframe.values_raw = { data_path: tuple(iter_channel_values(frame_num, grouped_fcurves[data_path], fkeys, num_channels, defaults[data_path]))
                                    for data_path, num_channels in channels.items() }
            keyframe.values = self._santize_converted_values(result_channels, keyframe.values_raw, convert)

            # Very gnawty
            keyframe.in_tans = [0.0] * result_channels
            keyframe.out_tans = [0.0] * result_channels
            keyframes[frame_num] = keyframe

        return self._sort_and_dedupe_keyframes(keyframes)

    def _process_keyframes(self, fcurves, num_channels: int, default_values: Sequence,
                           convert: Optional[Callable] = None, *, start: Optional[int] = None,
                           end: Optional[int] = None) -> Tuple[Sequence, AbstractSet]:
        """Groups all FCurves for the same frame together"""
        keyframe_data = type("KeyFrameData", (), {})
        fps, pi = self._bl_fps, math.pi

        keyframes, fcurve_keyframes = {}, defaultdict(dict)

        if start is not None and end is not None:
            framenum_filter = lambda x: x.co[0] >= start and x.co[0] <= end
        elif start is not None and end is None:
            framenum_filter = lambda x: x.co[0] >= start
        elif start is None and end is not None:
            framenum_filter = lambda x: x.co[0] <= end
        else:
            framenum_filter = lambda x: True

        indexed_fcurves = { fcurve.array_index: fcurve for fcurve in fcurves if fcurve is not None }
        for i, fcurve in indexed_fcurves.items():
            fcurve.update()
            for fkey in filter(framenum_filter, fcurve.keyframe_points):
                fcurve_keyframes[fkey.co[0]][i] = fkey

        def iter_values(frame_num, fkeys) -> Generator[float, None, None]:
            for i in range(num_channels):
                fkey = fkeys.get(i, None)
                if fkey is not None:
                    yield fkey.co[1]
                else:
                    fcurve = indexed_fcurves.get(i, None)
                    if fcurve is not None:
                        yield fcurve.evaluate(frame_num)
                    else:
                        yield default_values[i]

        # Does this really need to be a set?
        bez_chans = set()

        for frame_num, fkeys in fcurve_keyframes.items():
            keyframe = keyframe_data()
            # hope you don't have a frame 29.9 and frame 30.0...
            keyframe.frame_num = int(frame_num * (30.0 / fps))
            keyframe.frame_num_blender = frame_num
            keyframe.frame_time = frame_num / fps
            keyframe.in_tans = [0.0] * num_channels
            keyframe.out_tans = [0.0] * num_channels
            keyframe.values_raw = tuple(iter_values(frame_num, fkeys))
            if convert is None:
                keyframe.values = keyframe.values_raw
            else:
                keyframe.values = self._santize_converted_values(num_channels, keyframe.values_raw, convert)

            for i, fkey in ((i, fkey) for i, fkey in fkeys.items() if fkey.interpolation == "BEZIER"):
                value = keyframe.values_raw[i]
                keyframe.in_tans[i] = -(value - fkey.handle_left[1])  / (frame_num - fkey.handle_left[0])  / fps / (2 * pi)
                keyframe.out_tans[i] = (value - fkey.handle_right[1]) / (frame_num - fkey.handle_right[0]) / fps / (2 * pi)
                bez_chans.add(i)
            keyframes[frame_num] = keyframe

        # Return the keyframes in a sequence sorted by frame number
        return (self._sort_and_dedupe_keyframes(keyframes), bez_chans)

    @property
    def _mgr(self):
        return self._exporter().mgr
