#
# Copyright 2018 Pixar
#
# Licensed under the terms set forth in the LICENSE.txt file available at
# https://openusd.org/license.
#

from __future__ import division
from __future__ import print_function

from math import atan, radians as rad
from pxr import Gf, Tf

from .qt import QtCore
from .common import DEBUG_CLIPPING

# FreeCamera inherits from QObject only so that it can send signals...
# which is really a pretty nice, easy to use notification system.
class FreeCamera(QtCore.QObject):

    # Allows FreeCamera owner to act when the camera's relationship to
    # its viewed content changes.  For instance, to compute the value
    # to supply for setClosestVisibleDistFromPoint()
    signalFrustumChanged = QtCore.Signal()
    signalFrustumSettingsChanged = QtCore.Signal()

    defaultNear = 1
    defaultFar = 2000000
    # When some of the scene is behind the camera, this is the largest fraction
    # of the scene still-in-front-of-the-camera that we are willing to clip
    # while computing a non-zero near-clipping plane
    maxAllowableSceneClipFactor = .01
    # Experimentally on Nvidia M6000, if Far/Near is greater than this,
    # then geometry in the back half of the volume will disappear
    maxSafeZResolution = 1e6
    # Experimentally on Nvidia M6000, if Far/Near is greater than this,
    # then we will often see Z-fighting artifacts even for geometry that
    # is close to camera, when rendering for picking
    maxGoodZResolution = 5e4

    def __init__(self, isZUp, fov=60.0, aspectRatio=1.0, overrideNear=None,
                 overrideFar=None):
        """FreeCamera can be either a Z up or Y up camera, based on 'zUp'"""
        super(FreeCamera, self).__init__()

        self._camera = Gf.Camera()
        self._camera.SetPerspectiveFromAspectRatioAndFieldOfView(
            aspectRatio, fov, Gf.Camera.FOVVertical)
        self._overrideNear = overrideNear
        self._overrideFar = overrideFar
        self.resetClippingPlanes()

        self._isZUp = isZUp

        self._cameraTransformDirty = True
        self._rotTheta = 0
        self._rotPhi = 0
        self._rotPsi = 0
        self._center = Gf.Vec3d(0,0,0)
        self._dist = 100
        self._camera.focusDistance = self._dist
        self._closestVisibleDist = None
        self._lastFramedDist = None
        self._lastFramedClosestDist = None
        self._selSize = 10

        if isZUp:
            # This is also Gf.Camera.Y_UP_TO_Z_UP_MATRIX
            self._YZUpMatrix = Gf.Matrix4d().SetRotate(
                Gf.Rotation(Gf.Vec3d.XAxis(), -90))
            self._YZUpInvMatrix = self._YZUpMatrix.GetInverse()
        else:
            self._YZUpMatrix = Gf.Matrix4d(1.0)
            self._YZUpInvMatrix = Gf.Matrix4d(1.0)

    # Why a clone() method vs copy.deepcopy()ing the FreeCamera ?
    # 1) Several of the Gf classes are not python-picklable (requirement of
    #    deepcopy), nor is GfCamera.  Adding that infrastructure for this
    #    single client seems weighty.
    # 2) We could make FreeCamera itself be picklable... that solution would
    #    require twice as much code as clone().  If we wind up extracting
    #    FreeCamera to be a more general building block, it may be worth it,
    #    and clone() would transition to __getstate__().
    def clone(self):
        clone = FreeCamera(self._isZUp)
        clone._camera = Gf.Camera(self._camera)
        # skipping stereo attrs for now

        clone._rotTheta = self._rotTheta
        clone._rotPhi = self._rotPhi
        clone._rotPsi = self._rotPsi
        clone._center = Gf.Vec3d(self._center)
        clone._dist = self._dist
        clone._closestVisibleDist = self._closestVisibleDist
        clone._lastFramedClosestDist = self._lastFramedClosestDist
        clone._lastFramedDist = self._lastFramedDist
        clone._selSize = self._selSize
        clone._overrideNear = self._overrideNear
        clone._overrideFar = self._overrideFar
        clone._YZUpMatrix = Gf.Matrix4d(self._YZUpMatrix)
        clone._YZUpInvMatrix = Gf.Matrix4d(self._YZUpInvMatrix)

        return clone


    def _pushToCameraTransform(self):
        """
        Updates the camera's transform matrix, that is, the matrix that brings
        the camera to the origin, with the camera view pointing down:
           +Y if this is a Zup camera, or
           -Z if this is a Yup camera .
        """
        if not self._cameraTransformDirty:
            return

        def RotMatrix(vec, angle):
            return Gf.Matrix4d(1.0).SetRotate(Gf.Rotation(vec, angle))

        # self._YZUpInvMatrix influences the behavior about how the
        # FreeCamera will tumble. It is the identity or a rotation about the
        # x-Axis.
        self._camera.transform = (
            Gf.Matrix4d().SetTranslate(Gf.Vec3d.ZAxis() * self.dist) *
            RotMatrix(Gf.Vec3d.ZAxis(), -self._rotPsi) *
            RotMatrix(Gf.Vec3d.XAxis(), -self._rotPhi) *
            RotMatrix(Gf.Vec3d.YAxis(), -self._rotTheta) *
            self._YZUpInvMatrix *
            Gf.Matrix4d().SetTranslate(self.center))
        self._camera.focusDistance = self.dist

        self._cameraTransformDirty = False

    def _pullFromCameraTransform(self):
        """
        Updates parameters (center, rotTheta, etc.) from the camera transform.
        """
        # reads the transform set on the camera and updates all the other
        # parameters.  This is the inverse of _pushToCameraTransform
        cam_transform = self._camera.transform
        dist = self._camera.focusDistance
        frustum = self._camera.frustum
        cam_pos = frustum.position
        cam_axis = frustum.ComputeViewDirection()

        # Compute translational parts
        self._dist = dist
        self._selSize = dist / 10.0
        self._center = cam_pos + dist * cam_axis

        # self._YZUpMatrix influences the behavior about how the
        # FreeCamera will tumble. It is the identity or a rotation about the
        # x-Axis.

        # Compute rotational part
        transform = cam_transform * self._YZUpMatrix
        transform.Orthonormalize()
        rotation = transform.ExtractRotation()

        # Decompose and set angles
        self._rotTheta, self._rotPhi, self._rotPsi = -rotation.Decompose(
            Gf.Vec3d.YAxis(), Gf.Vec3d.XAxis(), Gf.Vec3d.ZAxis())

        self._cameraTransformDirty = True


    def _rangeOfBoxAlongRay(self, camRay, bbox, debugClipping=False):
        maxDist = -float('inf')
        minDist = float('inf')
        boxRange = bbox.GetRange()
        boxXform = bbox.GetMatrix()
        for i in range (8):
            # for each corner of the bounding box, transform to world
            # space and project
            point = boxXform.Transform(boxRange.GetCorner(i))
            pointDist = camRay.FindClosestPoint(point)[1]

            # find the projection of that point of the camera ray
            # and find the farthest and closest point.
            if pointDist > maxDist:
                maxDist = pointDist
            if pointDist < minDist:
                minDist = pointDist

        if debugClipping:
            print("Projected bounds near/far: %f, %f" % (minDist, maxDist))

        # if part of the bbox is behind the ray origin (i.e. camera),
        # minDist will be zero, which is not usefull as a near-clip
        # distance.  In this case, give it a non-zero value that is
        # responsive to the actual scale of the scene.  If the scene
        # is fully in front of the camera, instead reduce minDist by a
        # bit so that geometry at exactly the edge of the bounds won't
        # be clipped - do the same for maxDist, also!
        if minDist == 0:
            minDist = min(FreeCamera.defaultNear,
                          (maxDist - minDist) *
                          FreeCamera.maxAllowableSceneClipFactor)
        else:
            minDist *= 0.99
        maxDist *= 1.01

        if debugClipping:
            print("Contracted bounds near/far: %f, %f" % (minDist, maxDist))

        return minDist, maxDist

    def setClippingPlanes(self, stageBBox):
        '''Computes and sets automatic clipping plane distances using the
           camera's position and orientation, the bouding box
           surrounding the stage, and the distance to the closest rendered
           object in the central view of the camera (closestVisibleDist).

           If either of the "override" clipping attributes are not None,
           we use those instead'''

        debugClipping = Tf.Debug.IsDebugSymbolNameEnabled(DEBUG_CLIPPING)

        # If the scene bounding box is empty, or we are fully on manual
        # override, then just initialize to defaults.
        if stageBBox.GetRange().IsEmpty() or \
               (self._overrideNear and self._overrideFar) :
            computedNear, computedFar = FreeCamera.defaultNear, FreeCamera.defaultFar
        else:
            # The problem: We want to include in the camera frustum all the
            # geometry the viewer should be able to see, i.e. everything within
            # the inifinite frustum starting at distance epsilon from the
            # camera itself.  However, the further the imageable geometry is
            # from the near-clipping plane, the less depth precision we will
            # have to resolve nearly colinear/incident polygons (which we get
            # especially with any doubleSided geometry).  We can run into such
            # situations astonishingly easily with large sets when we are
            # focussing in on just a part of a set that spans 10^5 units or
            # more.
            #
            # Our solution: Begin by projecting the endpoints of the imageable
            # world's bounds onto the ray piercing the center of the camera
            # frustum, and take the near/far clipping distances from its
            # extent, clamping at a positive value for near.  To address the
            # z-buffer precision issue, we rely on someone having told us how
            # close the closest imageable geometry actually is to the camera,
            # by having called setClosestVisibleDistFromPoint(). This gives us
            # the most liberal near distance we can use and not clip the
            # geometry we are looking at.  We actually choose some fraction of
            # that distance instead, because we do not expect the someone to
            # recompute the closest point with every camera manipulation, as
            # it can be expensive (we do emit signalFrustumChanged to notify
            # them, however).  We only use this if the current range of the
            # bbox-based frustum will have precision issues.
            frustum = self._camera.frustum
            camPos = frustum.position

            camRay = Gf.Ray(camPos, frustum.ComputeViewDirection())
            computedNear, computedFar = self._rangeOfBoxAlongRay(camRay,
                                                                 stageBBox,
                                                                 debugClipping)

            precisionNear = computedFar / FreeCamera.maxGoodZResolution

            if debugClipping:
                print("Proposed near for precision: {}, closestDist: {}"\
                    .format(precisionNear, self._closestVisibleDist))
            if self._closestVisibleDist:
                # Because of our concern about orbit/truck causing
                # clipping, make sure we don't go closer than half the
                # distance to the closest visible point
                halfClose = self._closestVisibleDist / 2.0

                if self._closestVisibleDist < self._lastFramedClosestDist:
                    # This can happen if we have zoomed in closer since
                    # the last time setClosestVisibleDistFromPoint() was called.
                    # Clamp to precisionNear, which gives a balance between
                    # clipping as we zoom in, vs bad z-fighting as we zoom in.
                    # See AdjustDistance() for comment about better solution.
                    halfClose = max(precisionNear, halfClose, computedNear)
                    if debugClipping:
                        print("ADJUSTING: Accounting for zoom-in")

                if halfClose < computedNear:
                    # If there's stuff very very close to the camera, it
                    # may have been clipped by computedNear.  Get it back!
                    computedNear = halfClose
                    if debugClipping:
                        print("ADJUSTING: closestDist was closer than bboxNear")
                elif precisionNear > computedNear:
                    computedNear = min((precisionNear + halfClose) / 2.0,
                                       halfClose)
                    if debugClipping:
                        print("ADJUSTING: gaining precision by pushing out")

        near = self._overrideNear or computedNear
        far  = self._overrideFar  or computedFar
        # Make sure far is greater than near
        far = max(near+1, far)

        if debugClipping:
            print("***Final Near/Far: {}, {}".format(near, far))

        self._camera.clippingRange = Gf.Range1f(near, far)

    def computeGfCamera(self, stageBBox, autoClip=False):
        """Makes sure the FreeCamera's computed parameters are up-to-date, and
        returns the GfCamera object.  If 'autoClip' is True, then compute
        "optimal" positions for the near/far clipping planes based on the
        current closestVisibleDist, in order to maximize Z-buffer resolution"""
        self._pushToCameraTransform()
        if autoClip:
            self.setClippingPlanes(stageBBox)
        else:
            self.resetClippingPlanes()
        return self._camera

    def resetClippingPlanes(self):
        """Set near and far back to their uncomputed defaults."""
        near = self._overrideNear or FreeCamera.defaultNear
        far  = self._overrideFar  or FreeCamera.defaultFar
        self._camera.clippingRange = Gf.Range1f(near, far)


    def frameSelection(self, selBBox, frameFit):
        # needs to be recomputed
        self._closestVisibleDist = None

        self.center = selBBox.ComputeCentroid()
        selRange = selBBox.ComputeAlignedRange()
        self._selSize = max(*selRange.GetSize())
        if self.orthographic:
            self.fov = self._selSize * frameFit
            self.dist = self._selSize + FreeCamera.defaultNear
        else:
            halfFov = self.fov*0.5 or 0.5 # don't divide by zero
            lengthToFit = self._selSize * frameFit * 0.5
            self.dist = lengthToFit / atan(rad(halfFov))
            # Very small objects that fill out their bounding boxes (like cubes)
            # may well pierce our 1 unit default near-clipping plane. Make sure
            # that doesn't happen.
            if self.dist < FreeCamera.defaultNear + self._selSize * 0.5:
                self.dist = FreeCamera.defaultNear + lengthToFit

    def setClosestVisibleDistFromPoint(self, point):
        frustum = self._camera.frustum
        camPos = frustum.position
        camRay = Gf.Ray(camPos, frustum.ComputeViewDirection())
        self._closestVisibleDist = camRay.FindClosestPoint(point)[1]
        self._lastFramedDist = self.dist
        self._lastFramedClosestDist = self._closestVisibleDist

        if Tf.Debug.IsDebugSymbolNameEnabled(DEBUG_CLIPPING):
            print("Resetting closest distance to {}; CameraPos: {}, closestPoint: {}".format(self._closestVisibleDist, camPos, point))

    def ComputePixelsToWorldFactor(self, viewportHeight):
        '''Computes the ratio that converts pixel distance into world units.

        It treats the pixel distances as if they were projected to a plane going
        through the camera center.'''
        self._pushToCameraTransform()
        if self.orthographic:
            return self.fov / viewportHeight
        else:
            frustumHeight = self._camera.frustum.window.GetSize()[1]
            return frustumHeight * self._dist / viewportHeight

    def Tumble(self, dTheta, dPhi):
        ''' Tumbles the camera around the center point by (dTheta, dPhi) degrees. '''
        self._rotTheta += dTheta
        self._rotPhi += dPhi
        self._cameraTransformDirty = True
        self.signalFrustumChanged.emit()

    def AdjustDistance(self, scaleFactor):
        '''Scales the distance of the freeCamera from it's center typically by
        scaleFactor unless it puts the camera into a "stuck" state.'''

        # When dist gets very small, you can get stuck and not be able to
        # zoom back out, if you just keep multiplying.  Switch to addition
        # in that case, choosing an incr that works for the scale of the
        # framed geometry.
        if scaleFactor > 1 and self.dist < 2:
            selBasedIncr = self._selSize / 25.0
            scaleFactor -= 1.0
            self.dist += min(selBasedIncr, scaleFactor)
        else:
            self.dist *= scaleFactor

        # Make use of our knowledge that we are changing distance to camera
        # to also adjust _closestVisibleDist to keep it useful.  Make sure
        # not to recede farther than the last *computed* closeDist, since that
        # will generally cause unwanted clipping of close objects.
        # XXX:  This heuristic does a good job of preventing undesirable
        # clipping as we zoom in and out, but sacrifices the z-buffer
        # precision we worked hard to get.  If Hd/UsdImaging could cheaply
        # provide us with the closest-point from the last-rendered image,
        # we could use it safely here to update _closestVisibleDist much
        # more accurately than this calculation.
        if self._closestVisibleDist:
            if self.dist > self._lastFramedDist:
                self._closestVisibleDist = self._lastFramedClosestDist
            else:
                self._closestVisibleDist = \
                    self._lastFramedClosestDist - \
                    self._lastFramedDist + \
                    self.dist

    def Truck(self, deltaRight, deltaUp):
        ''' Moves the camera by (deltaRight, deltaUp) in worldspace coordinates. 

        This is similar to a camera Truck/Pedestal.
        '''
        # need to update the camera transform before we access the frustum
        self._pushToCameraTransform()
        frustum = self._camera.frustum
        cam_up = frustum.ComputeUpVector()
        cam_right = Gf.Cross(frustum.ComputeViewDirection(), cam_up)
        self._center += (deltaRight * cam_right + deltaUp * cam_up)
        self._cameraTransformDirty = True
        self.signalFrustumChanged.emit()

    def PanTilt(self, dPan, dTilt):
        ''' Rotates the camera around the current camera base (approx. the film
        plane).  Both parameters are in degrees.

        This moves the center point that we normally tumble around.

        This is similar to a camera Pan/Tilt.
        '''
        self._camera.transform = (
                Gf.Matrix4d(1.0).SetRotate(Gf.Rotation(Gf.Vec3d.XAxis(), dTilt)) *
                Gf.Matrix4d(1.0).SetRotate(Gf.Rotation(Gf.Vec3d.YAxis(), dPan)) *
                self._camera.transform)
        self._pullFromCameraTransform()

        # When we Pan/Tilt, we don't want to roll the camera so we just zero it
        # out here.
        self._rotPsi = 0.0

        self._cameraTransformDirty = True
        self.signalFrustumChanged.emit()

    def Walk(self, dForward, dRight):
        ''' Specialized camera movement that moves it on the "horizontal" plane
        '''
        # need to update the camera transform before we access the frustum
        self._pushToCameraTransform()
        frustum = self._camera.frustum
        cam_up = frustum.ComputeUpVector().GetNormalized()
        cam_forward = frustum.ComputeViewDirection().GetNormalized()
        cam_right = Gf.Cross(cam_forward, cam_up)
        delta = dForward * cam_forward + dRight * cam_right
        self._center += delta
        self._cameraTransformDirty = True
        self.signalFrustumChanged.emit()

    @staticmethod
    def FromGfCamera(gfCamera, isZUp):
        self = FreeCamera(isZUp)
        self._camera = gfCamera
        self._pullFromCameraTransform()
        return self

    @property
    def rotTheta(self):
        return self._rotTheta

    @rotTheta.setter
    def rotTheta(self, value):
        self._rotTheta = value
        self._cameraTransformDirty = True
        self.signalFrustumChanged.emit()

    @property
    def rotPhi(self):
        return self._rotPhi

    @rotPhi.setter
    def rotPhi(self, value):
        self._rotPhi = value
        self._cameraTransformDirty = True
        self.signalFrustumChanged.emit()

    @property
    def center(self):
        return self._center

    @center.setter
    def center(self, value):
        self._center = value
        self._cameraTransformDirty = True
        self.signalFrustumChanged.emit()

    @property
    def dist(self):
        return self._dist

    @dist.setter
    def dist(self, value):
        self._dist = value
        self._cameraTransformDirty = True
        self.signalFrustumChanged.emit()

    @property
    def orthographic(self):
        return self._camera.projection == Gf.Camera.Orthographic

    @orthographic.setter
    def orthographic(self, orthographic):
        if orthographic:
            self._camera.projection = Gf.Camera.Orthographic
        else:
            self._camera.projection = Gf.Camera.Perspective
        self.signalFrustumChanged.emit()
        self.signalFrustumSettingsChanged.emit()

    @property
    def fov(self):
        """The vertical field of view, in degrees, for perspective cameras. 
        For orthographic cameras fov is the height of the view frustum, in 
        world units.
        """
        if self._camera.projection == Gf.Camera.Perspective:
            return self._camera.GetFieldOfView(Gf.Camera.FOVVertical)
        else:
            return (self._camera.verticalAperture * Gf.Camera.APERTURE_UNIT)

    @fov.setter
    def fov(self, value):
        if self._camera.projection == Gf.Camera.Perspective:
            self._camera.SetPerspectiveFromAspectRatioAndFieldOfView(
                self._camera.aspectRatio, value, Gf.Camera.FOVVertical)
        else:
            self._camera.SetOrthographicFromAspectRatioAndSize(
                self._camera.aspectRatio, value, Gf.Camera.FOVVertical)
        self.signalFrustumChanged.emit()
        self.signalFrustumSettingsChanged.emit()

    @property
    def aspectRatio(self):
        return self._camera.aspectRatio

    @aspectRatio.setter
    def aspectRatio(self, value):
        """Sets the aspect ratio by adjusting the horizontal aperture."""
        self.horizontalAperture = value * self.verticalAperture

    @property
    def horizontalAperture(self):
        return self._camera.horizontalAperture

    @horizontalAperture.setter
    def horizontalAperture(self, value):
        self._camera.horizontalAperture = value
        self.signalFrustumChanged.emit()
        self.signalFrustumSettingsChanged.emit()

    @property
    def verticalAperture(self):
        return self._camera.verticalAperture

    @verticalAperture.setter
    def verticalAperture(self, value):
        self._camera.verticalAperture = value
        self.signalFrustumChanged.emit()
        self.signalFrustumSettingsChanged.emit()
    
    @property
    def focalLength(self):
        return self._camera.focalLength

    @focalLength.setter
    def focalLength(self, value):
        self._camera.focalLength = value
        self.signalFrustumChanged.emit()
        self.signalFrustumSettingsChanged.emit()

    @property
    def near(self):
        return self._camera.clippingRange.min


    @property
    def far(self):
        return self._camera.clippingRange.max

    # no setters for near and far - one must set overrideNear/Far instead
    @property
    def overrideNear(self):
        return self._overrideNear

    @overrideNear.setter
    def overrideNear(self, value):
        """To remove the override, set to None"""
        self._overrideNear = value

    @property
    def overrideFar(self):
        return self._overrideFar

    @overrideFar.setter
    def overrideFar(self, value):
        """To remove the override, set to None"""
        self._overrideFar = value
