//
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.
//
using UnityEngine;

namespace HoloToolkit.Unity
{

    /// <summary>
    ///   SurfaceMagnetism casts rays to Surfaces in the world align the object to the surface.
    /// </summary>
    public class SolverSurfaceMagnetism : Solver
    {
        #region public enums
        public enum RaycastDirectionEnum
        {
            CameraFacing,
            ToObject,
            ToLinkedPosition
        }
        public enum RaycastModeEnum
        {
            Simple,
            Box,
            Sphere
        }

        public enum OrientModeEnum
        {
            None,
            Vertical,
            Full,
            Blended
        }
        #endregion

        #region public members
        [Tooltip("LayerMask to apply Surface Magnetism to")]
        public LayerMask MagneticSurface = 0;

        [Tooltip("Max distance to check for surfaces")]
        public float MaxDistance = 3.0f;
        [Tooltip("Closest distance to bring object")]
        public float CloseDistance = 0.5f;

        [Tooltip("Offset from surface along surface normal")]
        public float SurfaceNormalOffset = 0.5f;
        [Tooltip("Offset from surface along ray cast direction")]
        public float SurfaceRayOffset = 0;

        [Tooltip("Surface raycast mode.  Simple = single raycast, Complex = bbox corners")]
        public RaycastModeEnum raycastMode = RaycastModeEnum.Simple;

        [Tooltip("Number of rays per edge, should be odd. Total casts is n^2")]
        public int BoxRaysPerEdge = 3;

        [Tooltip("If true, use orthographic casting for box lines instead of perspective")]
        public bool OrthoBoxCast = false;

        [Tooltip("Align to ray cast direction if box cast hits many normals facing in varying directions")]
        public float MaximumNormalVariance = 0.5f;

        [Tooltip("Radius to use for sphere cast")]
        public float SphereSize = 1.0f;

        [Tooltip("When doing volume casts, use size override if non-zero instead of object's current scale")]
        public float VolumeCastSizeOverride = 0;

        [Tooltip("When doing volume casts, use linked AltScale instead of object's current scale")]
        public bool UseLinkedAltScaleOverride = false;

        // This is broken
        [Tooltip("Instead of using mesh normal, extract normal from tex coord (SR is reported to put smoothed normals in there)")]
        bool UseTexCoordNormals = false;

        [Tooltip("Raycast direction.  Can cast from head in facing dir, or cast from head to object position")]
        public RaycastDirectionEnum raycastDirection = RaycastDirectionEnum.ToLinkedPosition;

        [Tooltip("Orientation mode.  None = no orienting, Vertical = Face head, but always oriented up/down, Full = Aligned to surface normal completely")]
        public OrientModeEnum orientationMode = OrientModeEnum.Vertical;

        [Tooltip("Orientation Blend Value 0.0 = All head 1.0 = All surface")]
        public float OrientBlend = 0.65f;

        [HideInInspector]
        public bool OnSurface;
        #endregion

        #region private members
        private BoxCollider m_BoxCollider;
        private const float maxDot = 0.97f;
        #endregion

        protected override void Start()
        {
            base.Start();

            if (raycastMode == RaycastModeEnum.Box)
            {
                m_BoxCollider = GetComponent<BoxCollider>();
                if (m_BoxCollider == null)
                {
                    Debug.LogError("Box raycast mode requires a BoxCollider, but none was found!  Defaulting to Simple raycast mode");
                    raycastMode = RaycastModeEnum.Simple;
                }

                if (Application.isEditor)
                {
                    RaycastHelper.DebugEnabled = true;
                }
            }

            if (Application.isEditor && UseTexCoordNormals)
            {
                Debug.LogWarning("Disabling tex coord normals while in editor mode");
                UseTexCoordNormals = false;
            }
        }

        /// <summary>
        ///   Wraps the raycast call in one spot.
        /// </summary>
        /// <param name="origin"></param>
        /// <param name="direction"></param>
        /// <param name="distance"></param>
        /// <param name="result"></param>
        /// <returns>bool, true if a surface was hit</returns>
        private static bool DefaultRaycast(Vector3 origin, Vector3 direction, float distance, LayerMask surface, out RaycastResultHelper result)
        {
            return RaycastHelper.First(origin, direction, distance, surface, out result);
        }

        private static bool DefaultSpherecast(Vector3 origin, Vector3 direction, float radius, float distance, LayerMask surface, out RaycastResultHelper result)
        {
            return RaycastHelper.SphereFirst(origin, direction, radius, distance, surface, out result);
        }

        /// <summary>
        ///   Where should rays originate from?
        /// </summary>
        /// <returns>Vector3</returns>
        Vector3 GetRaycastOrigin()
        {
            if (solverHandler.TransformTarget == null)
            {
                return Vector3.zero;
            }
            return solverHandler.TransformTarget.position;
		}

		/// <summary>
		///   Which point should the ray cast toward?  Not really the 'end' of the ray.  The ray may be cast along
		///   the head facing direction, from the eye to the object, or to the solver's linked position (working from
		///   the previous solvers)
		/// </summary>
		/// <returns>Vector3, a point on the ray besides the origin</returns>
		Vector3 GetRaycastEndPoint()
		{
            Vector3 ret = Vector3.forward;
            switch (raycastDirection)
            {
                case RaycastDirectionEnum.CameraFacing:
                    ret = solverHandler.TransformTarget.position + solverHandler.TransformTarget.forward;
                    break;

                case RaycastDirectionEnum.ToObject:
                    ret = transform.position;
                    break;

                case RaycastDirectionEnum.ToLinkedPosition:
                    ret = solverHandler.GoalPosition;
                    break;
            }
			return ret;
		}

		/// <summary>
		///   Calculate the raycast direction based on the two ray points
		/// </summary>
		/// <returns>Vector3, the direction of the raycast</returns>
		Vector3 GetRaycastDirection()
		{
			Vector3 ret = Vector3.forward;
			if (raycastDirection == RaycastDirectionEnum.CameraFacing)
			{

                if (solverHandler.TransformTarget)
                {
                    ret = solverHandler.TransformTarget.forward;
                }
            }
            else
            {
                ret = (GetRaycastEndPoint() - GetRaycastOrigin()).normalized;
            }
            return ret;
        }

        /// <summary>
        ///   Calculates how the object should orient to the surface.  May be none to pass shared orientation through,
        ///   oriented to the surface but fully vertical, fully oriented to the surface normal, or a slerped blend
        ///   of the vertial orientation and the pass-through rotation.
        /// </summary>
        /// <param name="rayDir"></param>
        /// <param name="surfaceNormal"></param>
        /// <returns>Quaternion, the orientation to use for the object</returns>
        Quaternion CalculateMagnetismOrientation(Vector3 rayDir, Vector3 surfaceNormal)
        {
            // Calculate the surface rotation
            Vector3 newDir = -surfaceNormal;
            if (IsNormalVertical(newDir))
            {
                newDir = rayDir;
            }

            newDir.y = 0;

            Quaternion surfaceRot = Quaternion.LookRotation(newDir, Vector3.up);

            switch (orientationMode)
            {
                case OrientModeEnum.None:
                    return solverHandler.GoalRotation;

                case OrientModeEnum.Vertical:
                    return surfaceRot;

                case OrientModeEnum.Full:
                    return Quaternion.LookRotation(-surfaceNormal, Vector3.up);

                case OrientModeEnum.Blended:
                    return Quaternion.Slerp(solverHandler.GoalRotation, surfaceRot, OrientBlend);
                default:
                    return Quaternion.identity;
            }
		}

		/// <summary>
		///   Checks if a normal is nearly vertical
		/// </summary>
		/// <param name="normal"></param>
		/// <returns>bool</returns>
		bool IsNormalVertical(Vector3 normal)
		{
			return 1f - Mathf.Abs(normal.y) < 0.01f;
		}

		/// <summary>
		///   A constant scale override may be specified for volumetric raycasts, oherwise uses the current value of the solver link's alt scale
		/// </summary>
		/// <returns>float</returns>
		float GetScaleOverride()
		{
			if (UseLinkedAltScaleOverride)
			{
				return solverHandler.AltScale.Current.magnitude;
			}
			return VolumeCastSizeOverride;
		}

		public override void SolverUpdate()
		{
			// Pass-through by default
			this.GoalPosition = WorkingPos;
			this.GoalRotation = WorkingRot;

			// Determine raycast params
			Ray ray = new Ray(GetRaycastOrigin(), GetRaycastDirection());

			// Skip if there's no valid direction
			if (ray.direction == Vector3.zero)
			{
				return;
			}

            float ScaleOverride = GetScaleOverride();
            float len;
            bool bHit;
            RaycastResultHelper result;
            Vector3 hitDelta;

            switch (raycastMode)
            {
                case RaycastModeEnum.Simple:
                default:

                    // Do the cast!
                    bHit = DefaultRaycast(ray.origin, ray.direction, MaxDistance, MagneticSurface, out result);

                    OnSurface = bHit;

                    if (UseTexCoordNormals)
                    {
                        result.OverrideNormalFromTextureCoord();
                    }

                    // Enforce CloseDistance
                    hitDelta = result.Point - ray.origin;
                    len = hitDelta.magnitude;
                    if (len < CloseDistance)
                    {
                        result.OverridePoint(ray.origin + ray.direction * CloseDistance);
                    }

                    // Apply results
                    if (bHit)
                    {
                        GoalPosition = result.Point + SurfaceNormalOffset * result.Normal + SurfaceRayOffset * ray.direction;
                        GoalRotation = CalculateMagnetismOrientation(ray.direction, result.Normal);
                    }
                    break;

                case RaycastModeEnum.Box:
                    
                    Vector3 scale = transform.lossyScale;
                    if (ScaleOverride > 0)
                    {
                        scale = scale.normalized * ScaleOverride;
                    }

                    Quaternion orientation = orientationMode == OrientModeEnum.None ? Quaternion.LookRotation(ray.direction, Vector3.up) : CalculateMagnetismOrientation(ray.direction, Vector3.up);
                    Matrix4x4 targetMatrix = Matrix4x4.TRS(Vector3.zero, orientation, scale);

                    if (m_BoxCollider == null)
                    {
                        m_BoxCollider = this.GetComponent<BoxCollider>();
                    }

                    Vector3 extents = m_BoxCollider.size;

                    Vector3[] positions;
                    Vector3[] normals;
                    bool[] hits;

                    if (RaycastHelper.CastBoxExtents(extents, transform.position, targetMatrix, ray, MaxDistance, MagneticSurface, DefaultRaycast, BoxRaysPerEdge, OrthoBoxCast, out positions, out normals, out hits))
                    {
                        Plane plane;
                        float distance;

                        // place an unconstrained plane down the ray.  Never use vertical constrain.
                        FindPlacementPlane(ray.origin, ray.direction, positions, normals, hits, m_BoxCollider.size.x, MaximumNormalVariance, false, orientationMode == OrientModeEnum.None, out plane, out distance);

                        // If placing on a horzizontal surface, need to adjust the calculated distance by half the app height
                        float verticalCorrectionOffset = 0;
                        if (IsNormalVertical(plane.normal) && !Mathf.Approximately(ray.direction.y, 0))
                        {
                            float boxSurfaceOffsetVert = targetMatrix.MultiplyVector(new Vector3(0, extents.y / 2f, 0)).magnitude;
                            Vector3 correctionVec = boxSurfaceOffsetVert * (ray.direction / ray.direction.y);
                            verticalCorrectionOffset = -correctionVec.magnitude;
                        }

                        float boxSurfaceOffset = targetMatrix.MultiplyVector(new Vector3(0, 0, extents.z / 2f)).magnitude;

                        // Apply boxSurfaceOffset to rayDir and not surfaceNormalDir to reduce sliding
                        GoalPosition = ray.origin + ray.direction * Mathf.Max(CloseDistance, distance + SurfaceRayOffset + boxSurfaceOffset + verticalCorrectionOffset) + plane.normal * (0 * boxSurfaceOffset + SurfaceNormalOffset);
                        GoalRotation = CalculateMagnetismOrientation(ray.direction, plane.normal);
                        OnSurface = true;
                    }
                    else
                    {
                        OnSurface = false;
                    }
                    break;

                case RaycastModeEnum.Sphere:

                    // Do the cast!
                    float size = ScaleOverride > 0 ? ScaleOverride : transform.lossyScale.x * SphereSize;
                    bHit = DefaultSpherecast(ray.origin, ray.direction, size, MaxDistance, MagneticSurface, out result);
                    OnSurface = bHit;

                    // Enforce CloseDistance
                    hitDelta = result.Point - ray.origin;
                    len = hitDelta.magnitude;
                    if (len < CloseDistance)
                    {
                        result.OverridePoint(ray.origin + ray.direction * CloseDistance);
                    }

                    // Apply results
                    if (bHit)
                    {
                        GoalPosition = result.Point + SurfaceNormalOffset * result.Normal + SurfaceRayOffset * ray.direction;
                        GoalRotation = CalculateMagnetismOrientation(ray.direction, result.Normal);
                    }
                    break;
            }

			// Do frame to frame updates of transform, smoothly toward the goal, if desired
			UpdateWorkingPosToGoal();
			UpdateWorkingRotToGoal();
		}

		/// <summary>
		///   Calculates a plane from all raycast hit locations upon which the object may align
		/// </summary>
		/// <param name="origin"></param>
		/// <param name="direction"></param>
		/// <param name="positions"></param>
		/// <param name="normals"></param>
		/// <param name="hits"></param>
		/// <param name="assetWidth"></param>
		/// <param name="maxNormalVariance"></param>
		/// <param name="constrainVertical"></param>
		/// <param name="bUseClosestDistance"></param>
		/// <param name="plane"></param>
		/// <param name="closestDistance"></param>
		private static void FindPlacementPlane(Vector3 origin, Vector3 direction, Vector3[] positions, Vector3[] normals, bool[] hits, float assetWidth, float maxNormalVariance, bool constrainVertical, bool bUseClosestDistance, out Plane plane, out float closestDistance)
		{
			bool debugEnabled = RaycastHelper.DebugEnabled;

			int numRays = positions.Length;

			Vector3 originalDirection = direction;
			if (constrainVertical)
			{
				direction.y = 0.0f;
				direction = direction.normalized;
			}

			// go through all the points and find the closest distance
			int closestPoint = -1;
			closestDistance = float.PositiveInfinity;
			float farthestDistance = 0f;
			int numHits = 0;
			Vector3 averageNormal = Vector3.zero;

			for (int i = 0; i < numRays; i++)
			{
				if (hits[i] != false)
				{
					float dist = Vector3.Dot(direction, positions[i] - origin);

					if (dist < closestDistance)
					{
						closestPoint = i;
						closestDistance = dist;
					}
					if (dist > farthestDistance)
					{
						farthestDistance = dist;
					}

					averageNormal += normals[i];
					++numHits;
				}
			}
			averageNormal /= numHits;

			// Calculate variance of all normals
			float variance = 0;
			for (int i = 0; i < numRays; ++i)
			{
				if (hits[i] != false)
				{
					variance += (normals[i] - averageNormal).magnitude;
				}
			}
			variance /= numHits;

			// If variance is too high, I really don't want to deal with this surface
			// And if we don't even have enough rays, I'm not confident about this at all
			if (variance > maxNormalVariance || numHits < numRays / 4)
			{
				plane = new Plane(-direction, positions[closestPoint]);
				return;
			}

			// go through all the points and find the most orthagonal plane
			float lowAngle = float.PositiveInfinity;
			int lowIndex = -1;
			float highAngle = float.NegativeInfinity;
			int highIndex = -1;

			for (int i = 0; i < numRays; i++)
			{
				if (hits[i] == false || i == closestPoint)
				{
					continue;
				}

				Vector3 diff = (positions[i] - positions[closestPoint]);
				if (constrainVertical)
				{
					diff.y = 0.0f;
					diff.Normalize();

					if (diff == Vector3.zero)
					{
						continue;
					}
				}
				else
				{
					diff.Normalize();
				}

				float angle = Vector3.Dot(direction, diff);

				if (angle < lowAngle)
				{
					lowAngle = angle;
					lowIndex = i;
				}
			}

			if (!constrainVertical && lowIndex != -1)
			{
				for (int i = 0; i < numRays; i++)
				{
					if (hits[i] == false || i == closestPoint || i == lowIndex)
					{
						continue;
					}

					float dot = Mathf.Abs(Vector3.Dot((positions[i] - positions[closestPoint]).normalized, (positions[lowIndex] - positions[closestPoint]).normalized));
					if (dot > maxDot)
					{
						continue;
					}

					Vector3 normal = Vector3.Cross(positions[lowIndex] - positions[closestPoint], positions[i] - positions[closestPoint]).normalized;

					float nextAngle = Mathf.Abs(Vector3.Dot(direction, normal));

					if (nextAngle > highAngle)
					{
						highAngle = nextAngle;
						highIndex = i;
					}
				}
			}

			Vector3 placementNormal;
			if (lowIndex != -1)
			{
				if (debugEnabled)
				{
					Debug.DrawLine(positions[closestPoint], positions[lowIndex], Color.red);
				}

				if (highIndex != -1)
				{
					if (debugEnabled)
					{
						Debug.DrawLine(positions[closestPoint], positions[highIndex], Color.green);
					}
					placementNormal = Vector3.Cross(positions[lowIndex] - positions[closestPoint], positions[highIndex] - positions[closestPoint]).normalized;
				}
				else
				{
					Vector3 planeUp = Vector3.Cross(positions[lowIndex] - positions[closestPoint], direction);
					placementNormal = Vector3.Cross(positions[lowIndex] - positions[closestPoint], constrainVertical ? Vector3.up : planeUp).normalized;
				}

				if (debugEnabled)
				{
					Debug.DrawLine(positions[closestPoint], positions[closestPoint] + placementNormal, Color.blue);
				}
			}
			else
			{
				placementNormal = direction * -1.0f;
			}

			if (Vector3.Dot(placementNormal, direction) > 0.0f)
			{
				placementNormal *= -1.0f;
			}

			plane = new Plane(placementNormal, positions[closestPoint]);

			if (debugEnabled)
			{
				Debug.DrawRay(positions[closestPoint], placementNormal, Color.cyan);
			}

			// Figure out how far the plane should be.
			if (!bUseClosestDistance && closestPoint >= 0)
			{
				float centerPlaneDistance;
				Ray centerPlaneRay = new Ray(origin, originalDirection);
				if (plane.Raycast(centerPlaneRay, out centerPlaneDistance) || centerPlaneDistance != 0)
				{
					// When the plane is nearly parallel to the user, we need to clamp the distance to where the raycasts hit.
					closestDistance = Mathf.Clamp(centerPlaneDistance, closestDistance, farthestDistance + assetWidth * 0.5f);
				}
				else
				{
					Debug.LogError("FindPlacementPlane: Not expected to have the center point not intersect the plane.");
				}
			}
		}
	}
}
