# -*- coding: UTF-8 -*-
# Copyright 2020 by Kurt Rathjen. All Rights Reserved.
#
# This library is free software: you can redistribute it and/or modify it 
# under the terms of the GNU Lesser General Public License as published by 
# the Free Software Foundation, either version 3 of the License, or 
# (at your option) any later version. This library 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 Lesser General Public License for more details.
# You should have received a copy of the GNU Lesser General Public
# License along with this library. If not, see <http://www.gnu.org/licenses/>.

import os
import shutil
import logging

from studiovendor.Qt import QtWidgets

import mutils
import mutils.gui

try:
    import maya.cmds
except ImportError:
    import traceback
    traceback.print_exc()

logger = logging.getLogger(__name__)


MIN_TIME_LIMIT = -10000
MAX_TIME_LIMIT = 100000
DEFAULT_FILE_TYPE = "mayaBinary"  # "mayaAscii"

# A feature flag that will be removed in the future.
FIX_SAVE_ANIM_REFERENCE_LOCKED_ERROR = True


class PasteOption:

    Insert = "insert"
    Replace = "replace"
    ReplaceAll = "replace all"
    ReplaceCompletely = "replaceCompletely"


class AnimationTransferError(Exception):
    """Base class for exceptions in this module."""
    pass


class OutOfBoundsError(AnimationTransferError):
    """Exceptions for clips or ranges that are outside the expected range"""
    pass


def validateAnimLayers():
    """
    Check if the selected animation layer can be exported.
    
    :raise: AnimationTransferError
    """
    if maya.cmds.about(q=True, batch=True):
        return

    animLayers = maya.mel.eval('$gSelectedAnimLayers=$gSelectedAnimLayers')

    # Check if more than one animation layer has been selected.
    if len(animLayers) > 1:
        msg = u"选择了多个动画层! \n请只选择一个动画图层进行导出!"

        raise AnimationTransferError(msg)

    # Check if the selected animation layer is locked
    if len(animLayers) == 1:
        if maya.cmds.animLayer(animLayers[0], query=True, lock=True):
            msg = u"无法导出锁定的动画层! \n导出动画前，请先解锁动画层!"

            raise AnimationTransferError(msg)


def saveAnim(
        objects,
        path,
        time=None,
        sampleBy=1,
        fileType="",
        metadata=None,
        iconPath="",
        sequencePath="",
        bakeConnected=True
):
    """
    Save the anim data for the given objects.

    Example:
        import mutils
        mutils.saveAnim(
            path="c:/example.anim", 
            objects=["control1", "control2"]
            time=(1, 20),
            metadata={'description': 'Example anim'}
            )
            
    :type path: str
    :type objects: None or list[str]
    :type time: (int, int) or None
    :type fileType: str or None
    :type sampleBy: int
    :type iconPath: str
    :type sequencePath: str
    :type metadata: dict or None
    :type bakeConnected: bool
    
    :rtype: mutils.Animation
    """
    # Copy the icon path to the temp location
    if iconPath:
        shutil.copyfile(iconPath, path + "/thumbnail.jpg")

    # Copy the sequence path to the temp location
    if sequencePath:
        shutil.move(sequencePath, path + "/sequence")

    # Save the animation to the temp location
    anim = mutils.Animation.fromObjects(objects)
    anim.updateMetadata(metadata)
    anim.save(
        path,
        time=time,
        sampleBy=sampleBy,
        fileType=fileType,
        bakeConnected=bakeConnected
    )
    return anim


def clampRange(srcTime, dstTime):
    """
    Clips the given source time to within the given destination time.

    Example:
        print clampRange((15, 35), (20, 30))
        # 20, 30

        print clampRange((25, 45), (20, 30))
        # 25, 30

    :type srcTime: (int, int)
    :type dstTime: (int, int)
    :rtype: (int, int)
    """
    srcStart, srcEnd = srcTime
    dstStart, dstEnd = dstTime

    if srcStart > dstEnd or srcEnd < dstStart:
        msg = "The src and dst time do not overlap. " \
              "Unable to clamp (src=%s, dest=%s)"
        raise OutOfBoundsError(msg, srcTime, dstTime)

    if srcStart < dstStart:
        srcStart = dstStart

    if srcEnd > dstEnd:
        srcEnd = dstEnd

    return srcStart, srcEnd


def moveTime(time, start):
    """
    Move the given time to the given start time.

    Example:
        print moveTime((15, 35), 5)
        # 5, 20

    :type time: (int, int)
    :type start: int
    :rtype: (int, int)
    """
    srcStartTime, srcEndTime = time
    duration = srcEndTime - srcStartTime

    if start is None:
        startTime = srcStartTime
    else:
        startTime = start

    endTime = startTime + duration

    if startTime == endTime:
        endTime = startTime + 1

    return startTime, endTime


def findFirstLastKeyframes(curves, time=None):
    """
    Return the first and last frame of the given animation curves

    :type curves: list[str]
    :type time: (int, int)
    :rtype: (int, int)
    """
    first = maya.cmds.findKeyframe(curves, which='first')
    last = maya.cmds.findKeyframe(curves, which='last')

    result = (first, last)

    if time:

        # It's possible (but unlikely) that the curves will not lie within the
        # first and last frame
        try:
            result = clampRange(time, result)
        except OutOfBoundsError as error:
            logger.warning(error)

    return result


def insertKeyframe(curves, time):
    """
    Insert a keyframe on the given curves at the given time.

    :type curves: list[str]
    :type time: (int, int)
    """
    startTime, endTime = time

    for curve in curves:
        insertStaticKeyframe(curve, time)

    firstFrame = maya.cmds.findKeyframe(curves, time=(startTime, startTime), which='first')
    lastFrame = maya.cmds.findKeyframe(curves, time=(endTime, endTime), which='last')

    if firstFrame < startTime < lastFrame:
        maya.cmds.setKeyframe(curves, insert=True, time=(startTime, startTime))

    if firstFrame < endTime < lastFrame:
        maya.cmds.setKeyframe(curves, insert=True, time=(endTime, endTime))


def insertStaticKeyframe(curve, time):
    """
    Insert a static keyframe on the given curve at the given time.

    :type curve: str
    :type time: (int, int)
    :rtype: None
    """
    startTime, endTime = time

    lastFrame = maya.cmds.findKeyframe(curve, which='last')
    firstFrame = maya.cmds.findKeyframe(curve, which='first')

    if firstFrame == lastFrame:
        maya.cmds.setKeyframe(curve, insert=True, time=(startTime, endTime))
        maya.cmds.keyTangent(curve, time=(startTime, startTime), ott="step")

    if startTime < firstFrame:
        nextFrame = maya.cmds.findKeyframe(curve, time=(startTime, startTime), which='next')
        if startTime < nextFrame < endTime:
            maya.cmds.setKeyframe(curve, insert=True, time=(startTime, nextFrame))
            maya.cmds.keyTangent(curve, time=(startTime, startTime), ott="step")

    if endTime > lastFrame:
        previousFrame = maya.cmds.findKeyframe(curve, time=(endTime, endTime), which='previous')
        if startTime < previousFrame < endTime:
            maya.cmds.setKeyframe(curve, insert=True, time=(previousFrame, endTime))
            maya.cmds.keyTangent(curve, time=(previousFrame, previousFrame), ott="step")


def duplicateNode(node, name):
    """Duplicate the given node.

    :param node: Maya path.
    :type node: str
    :param name: Name for the duplicated node.
    :type name: str
    :returns: Duplicated node names.
    :rtype: list[str]
    """
    if maya.cmds.nodeType(node) in ["transform", "joint"]:
        new = maya.cmds.duplicate(node, name=name, parentOnly=True)[0]
    else:
        # Please let us know if this logic is causing issues.
        new = maya.cmds.duplicate(node, name=name)[0]
        shapes = maya.cmds.listRelatives(new, shapes=True) or []
        if shapes:
            return [shapes[0], new]
    return [new]


def loadAnims(
    paths,
    spacing=1,
    objects=None,
    option=None,
    connect=False,
    namespaces=None,
    startFrame=None,
    mirrorTable=None,
    currentTime=None,
    showDialog=False,
):
    """
    Load the animations in the given order of paths with the spacing specified.

    :type paths: list[str]
    :type spacing: int
    :type connect: bool
    :type objects: list[str]
    :type namespaces: list[str]
    :type startFrame: int
    :type option: PasteOption
    :type currentTime: bool
    :type mirrorTable: bool
    :type showDialog: bool
    """
    isFirstAnim = True

    if spacing < 1:
        spacing = 1

    if option is None or option == "replace all":
        option = PasteOption.ReplaceCompletely

    if currentTime and startFrame is None:
        startFrame = int(maya.cmds.currentTime(query=True))

    if showDialog:

        msg = "Load the following animation in sequence;\n"

        for i, path in enumerate(paths):
            msg += "\n {0}. {1}".format(i, os.path.basename(path))

        msg += "\n\nPlease choose the spacing between each animation."

        spacing, accepted = QtWidgets.QInputDialog.getInt(
            None,
            "Load animation sequence",
            msg,
            spacing,
            QtWidgets.QInputDialog.NoButtons,
        )

        if not accepted:
            raise Exception("Dialog canceled!")

    for path in paths:

        anim = mutils.Animation.fromPath(path)

        if startFrame is None and isFirstAnim:
            startFrame = anim.startFrame()

        if option == "replaceCompletely" and not isFirstAnim:
            option = "insert"

        anim.load(
            option=option,
            objects=objects,
            connect=connect,
            startFrame=startFrame,
            namespaces=namespaces,
            currentTime=currentTime,
            mirrorTable=mirrorTable,
        )

        duration = anim.endFrame() - anim.startFrame()
        startFrame += duration + spacing
        isFirstAnim = False


class Animation(mutils.Pose):

    IMPORT_NAMESPACE = "REMOVE_IMPORT"

    @classmethod
    def fromPath(cls, path):
        """
        Create and return an Anim object from the give path.

        Example:
            anim = Animation.fromPath("/temp/example.anim")
            print anim.endFrame()
            # 14

        :type path: str
        :rtype: Animation
        """
        anim = cls()
        anim.setPath(path)
        anim.read()
        return anim

    def __init__(self):
        mutils.Pose.__init__(self)

        try:
            timeUnit = maya.cmds.currentUnit(q=True, time=True)
            linearUnit = maya.cmds.currentUnit(q=True, linear=True)
            angularUnit = maya.cmds.currentUnit(q=True, angle=True)

            self.setMetadata("timeUnit", timeUnit)
            self.setMetadata("linearUnit", linearUnit)
            self.setMetadata("angularUnit", angularUnit)
        except NameError as msg:
            logger.exception(msg)

    def select(self, objects=None, namespaces=None, **kwargs):
        """
        Select the objects contained in the animation.
        
        :type objects: list[str] or None
        :type namespaces: list[str] or None
        :rtype: None
        """
        selectionSet = mutils.SelectionSet.fromPath(self.poseJsonPath())
        selectionSet.load(objects=objects, namespaces=namespaces, **kwargs)

    def startFrame(self):
        """
        Return the start frame for anim object.

        :rtype: int
        """
        return self.metadata().get("startFrame")

    def endFrame(self):
        """
        Return the end frame for anim object.

        :rtype: int
        """
        return self.metadata().get("endFrame")

    def mayaPath(self):
        """
        :rtype: str
        """
        mayaPath = os.path.join(self.path(), "animation.mb")
        if not os.path.exists(mayaPath):
            mayaPath = os.path.join(self.path(), "animation.ma")
        return mayaPath

    def poseJsonPath(self):
        """
        :rtype: str
        """
        return os.path.join(self.path(), "pose.json")

    def paths(self):
        """
        Return all the paths for Anim object.

        :rtype: list[str]
        """
        result = []
        if os.path.exists(self.mayaPath()):
            result.append(self.mayaPath())

        if os.path.exists(self.poseJsonPath()):
            result.append(self.poseJsonPath())

        return result

    def animCurve(self, name, attr, withNamespace=False):
        """
        Return the animCurve for the given object name and attribute.

        :type name: str
        :type attr: str
        :type withNamespace: bool

        :rtype: str
        """
        curve = self.attr(name, attr).get("curve", None)
        if curve and withNamespace:
            curve = Animation.IMPORT_NAMESPACE + ":" + curve
        return curve

    def setAnimCurve(self, name, attr, curve):
        """
        Set the animCurve for the given object name and attribute.

        :type name: str
        :type attr: str
        :type curve: str
        """
        self.objects()[name].setdefault("attrs", {})
        self.objects()[name]["attrs"].setdefault(attr, {})
        self.objects()[name]["attrs"][attr]["curve"] = curve

    def read(self, path=None):
        """
        Read all the data to be used by the Anim object.

        :rtype: None
        """
        path = self.poseJsonPath()

        logger.debug("Reading: " + path)
        mutils.Pose.read(self, path=path)
        logger.debug("Reading Done")

    def isAscii(self, s):
        """Check if the given string is a valid ascii string."""
        return all(ord(c) < 128 for c in s)

    @mutils.unifyUndo
    @mutils.restoreSelection
    def open(self):
        """
        The reason we use importing and not referencing is because we
        need to modify the imported animation curves and modifying
        referenced animation curves is only supported in Maya 2014+
        """
        self.close()  # Make sure everything is cleaned before importing


        nodes = maya.cmds.file(
            self.mayaPath(),
            i=True,
            groupLocator=True,
            ignoreVersion=True,
            returnNewNodes=True,
            namespace=Animation.IMPORT_NAMESPACE,
        )

        return nodes

    def close(self):
        """
        Clean up all imported nodes, as well as the namespace.
        Should be called in a finally block.
        """
        nodes = maya.cmds.ls(Animation.IMPORT_NAMESPACE + ":*", r=True) or []
        if nodes:
            maya.cmds.delete(nodes)

        # It is important that we remove the imported namespace,
        # otherwise another namespace will be created on next
        # animation open.
        namespaces = maya.cmds.namespaceInfo(ls=True) or []

        if Animation.IMPORT_NAMESPACE in namespaces:
            maya.cmds.namespace(set=':')
            maya.cmds.namespace(rm=Animation.IMPORT_NAMESPACE)

    def cleanMayaFile(self, path):
        """
        Clean up all commands in the exported maya file that are
        not createNode.
        """
        results = []

        if path.endswith(".mb"):
            return

        with open(path, "r") as f:
            for line in f.readlines():
                if not line.startswith("select -ne"):
                    results.append(line)
                else:
                    results.append("// End")
                    break

        with open(path, "w") as f:
            f.writelines(results)

    @mutils.timing
    @mutils.unifyUndo
    @mutils.showWaitCursor
    @mutils.restoreSelection
    def save(
        self,
        path,
        time=None,
        sampleBy=1,
        fileType="",
        bakeConnected=True
    ):
        """
        Save all animation data from the objects set on the Anim object.

        :type path: str
        :type time: (int, int) or None
        :type sampleBy: int
        :type fileType: str
        :type bakeConnected: bool
        
        :rtype: None
        """
        objects = list(self.objects().keys())

        fileType = fileType or DEFAULT_FILE_TYPE

        if not time:
            time = mutils.selectedObjectsFrameRange(objects)
        start, end = time

        # Check selected animation layers
        validateAnimLayers()

        # Check frame range
        if start is None or end is None:
            msg = u"请指定开始和结束帧!"
            raise AnimationTransferError(msg)

        if start >= end:
            msg = u"开始帧不能大于或等于结束帧!"
            raise AnimationTransferError(msg)

        # Check if animation exists
        if mutils.getDurationFromNodes(objects or [], time=time) <= 0:
            msg = u"在选定对象上找不到动画，请做个动画去吧！"
            raise AnimationTransferError(msg)

        self.setMetadata("endFrame", end)
        self.setMetadata("startFrame", start)

        end += 1
        validCurves = []
        deleteObjects = []

        msg = u"Animation.save(path={0}, time={1}, bakeConnections={2}, sampleBy={3})"
        msg = msg.format(path, str(time), str(bakeConnected), str(sampleBy))
        logger.debug(msg)

        try:
            if bakeConnected:
                maya.cmds.undoInfo(openChunk=True)
                mutils.bakeConnected(objects, time=(start, end), sampleBy=sampleBy)

            for name in objects:
                if maya.cmds.copyKey(name, time=(start, end), includeUpperBound=False, option="keys"):
                    dstNodes = duplicateNode(name, "CURVE")
                    dstNode = dstNodes[0]
                    deleteObjects.extend(dstNodes)

                    if not FIX_SAVE_ANIM_REFERENCE_LOCKED_ERROR:
                        mutils.disconnectAll(dstNode)

                    # Make sure we delete all proxy attributes, otherwise pasteKey will duplicate keys
                    mutils.Attribute.deleteProxyAttrs(dstNode)
                    maya.cmds.pasteKey(dstNode)

                    attrs = maya.cmds.listAttr(dstNode, unlocked=True, keyable=True) or []
                    attrs = list(set(attrs) - set(['translate', 'rotate', 'scale']))

                    for attr in attrs:
                        dstAttr = mutils.Attribute(dstNode, attr)
                        dstCurve = dstAttr.animCurve()

                        if dstCurve:

                            dstCurve = maya.cmds.rename(dstCurve, "CURVE")
                            deleteObjects.append(dstCurve)

                            srcAttr = mutils.Attribute(name, attr)
                            srcCurve = srcAttr.animCurve()

                            if srcCurve:
                                preInfinity = maya.cmds.getAttr(srcCurve + ".preInfinity")
                                postInfinity = maya.cmds.getAttr(srcCurve + ".postInfinity")
                                curveColor = maya.cmds.getAttr(srcCurve + ".curveColor")
                                useCurveColor = maya.cmds.getAttr(srcCurve + ".useCurveColor")

                                maya.cmds.setAttr(dstCurve + ".preInfinity", preInfinity)
                                maya.cmds.setAttr(dstCurve + ".postInfinity", postInfinity)
                                maya.cmds.setAttr(dstCurve + ".curveColor", *curveColor[0])
                                maya.cmds.setAttr(dstCurve + ".useCurveColor", useCurveColor)

                            if maya.cmds.keyframe(dstCurve, query=True, time=(start, end), keyframeCount=True):
                                self.setAnimCurve(name, attr, dstCurve)
                                maya.cmds.cutKey(dstCurve, time=(MIN_TIME_LIMIT, start - 1))
                                maya.cmds.cutKey(dstCurve, time=(end + 1, end + MAX_TIME_LIMIT))
                                validCurves.append(dstCurve)

            fileName = "animation.ma"
            if fileType == "mayaBinary":
                fileName = "animation.mb"

            mayaPath = os.path.join(path, fileName)
            posePath = os.path.join(path, "pose.json")
            mutils.Pose.save(self, posePath)

            if validCurves:
                maya.cmds.select(validCurves)
                logger.info(u"保存动画到: %s" % mayaPath)
                maya.cmds.file(mayaPath, force=True, options='v=0', type=fileType, uiConfiguration=False, exportSelected=True)
                self.cleanMayaFile(mayaPath)

        finally:
            if bakeConnected:
                # HACK! Undo all baked connections. :)
                maya.cmds.undoInfo(closeChunk=True)
                maya.cmds.undo()
            elif deleteObjects:
                maya.cmds.delete(deleteObjects)

        self.setPath(path)

    @mutils.timing
    @mutils.showWaitCursor
    def load(
            self,
            objects=None,
            namespaces=None,
            attrs=None,
            startFrame=None,
            sourceTime=None,
            option=None,
            connect=False,
            mirrorTable=None,
            currentTime=None
    ):
        """
        Load the animation data to the given objects or namespaces.

        :type objects: list[str]
        :type namespaces: list[str]
        :type startFrame: int
        :type sourceTime: (int, int) or None
        :type attrs: list[str]
        :type option: PasteOption or None
        :type connect: bool
        :type mirrorTable: mutils.MirrorTable
        :type currentTime: bool or None
        """
        logger.info(u'Loading: {0}'.format(self.path()))

        connect = bool(connect)  # Make false if connect is None

        if not sourceTime:
            sourceTime = (self.startFrame(), self.endFrame())

        if option and option.lower() == "replace all":
            option = "replaceCompletely"

        if option is None or option == PasteOption.ReplaceAll:
            option = PasteOption.ReplaceCompletely

        self.validate(namespaces=namespaces)

        objects = objects or []

        logger.debug("Animation.load(objects=%s, option=%s, namespaces=%s, srcTime=%s, currentTime=%s)" %
                     (len(objects), str(option), str(namespaces), str(sourceTime), str(currentTime)))

        srcObjects = self.objects().keys()

        if mirrorTable:
            self.setMirrorTable(mirrorTable)

        valid = False
        matches = list(mutils.matchNames(srcObjects=srcObjects, dstObjects=objects, dstNamespaces=namespaces))

        for srcNode, dstNode in matches:
            if dstNode.exists():
                valid = True
                break

        if not matches or not valid:

            text = u"   加载数据时没有对象匹配.\n   打开调试模式以查看更多细节."

            raise mutils.NoMatchFoundError(text)

        # Load the animation data.
        srcCurves = self.open()

        try:
            maya.cmds.flushUndo()
            maya.cmds.undoInfo(openChunk=True)

            if currentTime and startFrame is None:
                startFrame = int(maya.cmds.currentTime(query=True))

            srcTime = findFirstLastKeyframes(srcCurves, sourceTime)
            dstTime = moveTime(srcTime, startFrame)

            if option != PasteOption.ReplaceCompletely:
                insertKeyframe(srcCurves, srcTime)

            for srcNode, dstNode in matches:

                # Remove the first pipe in-case the object has a parent
                dstNode.stripFirstPipe()

                for attr in self.attrs(srcNode.name()):

                    # Filter any attributes if the parameter has been set
                    if attrs is not None and attr not in attrs:
                        continue

                    dstAttr = mutils.Attribute(dstNode.name(), attr)

                    if not dstAttr.exists():
                        logger.debug(u'跳过属性: 目标属性 "%s" 不存在!' % dstAttr.fullname())
                        continue

                    if dstAttr.isProxy():
                        logger.debug(u'跳过属性: 目标属性 "%s" 是代理属性!', dstAttr.fullname())
                        continue

                    srcCurve = self.animCurve(srcNode.name(), attr, withNamespace=True)

                    if srcCurve:
                        dstAttr.setAnimCurve(
                            srcCurve,
                            time=dstTime,
                            option=option,
                            source=srcTime,
                            connect=connect
                        )
                    else:
                        value = self.attrValue(srcNode.name(), attr)
                        dstAttr.setStaticKeyframe(value, dstTime, option)

        finally:
            self.close()
            maya.cmds.undoInfo(closeChunk=True)

            # Return the focus to the Maya window
            maya.cmds.setFocus("MayaWindow")

        logger.info(u'加载: {0}'.format(self.path()))
