using Intrepid.Utilities;
using Unity.Cinemachine;
using UnityEngine;

namespace Intrepid.Core.Camera
{
    [DisallowMultipleComponent]
    public sealed class CameraGroundProjectionConfinerExtension : CinemachineExtension
    {
        [GroupConfigurations, SerializeField] private bool confineX = true;
        [GroupConfigurations, SerializeField] private bool confineZ = true;
        [GroupConfigurations, SerializeField] private float fallbackProjectionY;
        [GroupConfigurations, SerializeField] private bool useFollowTargetY = true;
        [GroupConfigurations, SerializeField] private float projectionYSmoothTime = 0.08f;
        
        private float _currentProjectionY;
        private float _projectionYVelocity;
        private bool _hasProjectionY;
        private readonly Vector3[] _groundCorners = new Vector3[4];

        protected override void PostPipelineStageCallback(
            CinemachineVirtualCameraBase vcam,
            CinemachineCore.Stage stage,
            ref CameraState state,
            float deltaTime)
        {
            // Use Finalize so Body, Aim and Noise have already done their work.
            if (stage != CinemachineCore.Stage.Finalize) return;

            var lens = state.Lens;
            if (!lens.Orthographic) return;

            var manager = CameraSystem.Instance?.CameraBoundaryManager;
            if (manager is null) return;
            
            var cameraPosition = state.RawPosition + state.PositionCorrection;
            var cameraRotation = state.RawOrientation;
            var projectionY = GetProjectionY(vcam, deltaTime);
            CalculateOrthographicGroundCorners(cameraPosition, cameraRotation, lens, projectionY, _groundCorners);
            
            var weight = manager.SmoothBoundaryWeight;
            if (weight <= 0f) return;
            if (!manager.TryGetCameraBounds(out var bounds)) return;
            var correction = CalculateCorrection(_groundCorners, bounds);
            state.PositionCorrection += correction * weight;
        }
        
        private float GetProjectionY(CinemachineVirtualCameraBase vcam, float deltaTime)
        {
            var targetY = fallbackProjectionY;

            if (useFollowTargetY && vcam.Follow is not null)
            {
                targetY = vcam.Follow.position.y;
            }

            if (!_hasProjectionY || deltaTime < 0f || projectionYSmoothTime <= 0f)
            {
                _currentProjectionY = targetY;
                _projectionYVelocity = 0f;
                _hasProjectionY = true;
                return _currentProjectionY;
            }

            _currentProjectionY = Mathf.SmoothDamp(
                _currentProjectionY,
                targetY,
                ref _projectionYVelocity,
                projectionYSmoothTime,
                Mathf.Infinity,
                deltaTime
            );

            return _currentProjectionY;
        }

        private static void CalculateOrthographicGroundCorners(
            Vector3 cameraPosition,
            Quaternion cameraRotation,
            LensSettings lens,
            float groundY,
            Vector3[] results)
        {
            var halfHeight = lens.OrthographicSize;
            var halfWidth = lens.OrthographicSize * lens.Aspect;

            var right = cameraRotation * Vector3.right;
            var up = cameraRotation * Vector3.up;
            var forward = cameraRotation * Vector3.forward;

            // Ordem:
            // 0 = bottom-left
            // 1 = bottom-right
            // 2 = top-right
            // 3 = top-left
            Vector3[] localCorners =
            {
                (-right * halfWidth) + (-up * halfHeight),
                (right * halfWidth) + (-up * halfHeight),
                (right * halfWidth) + (up * halfHeight),
                (-right * halfWidth) + (up * halfHeight),
            };

            var groundPlane = new Plane(Vector3.up, new Vector3(0f, groundY, 0f));

            for (var i = 0; i < 4; i++)
            {
                var rayOrigin = cameraPosition + localCorners[i];
                var ray = new Ray(rayOrigin, forward);

                if (groundPlane.Raycast(ray, out var distance))
                {
                    results[i] = ray.GetPoint(distance);
                }
                else
                {
                    results[i] = rayOrigin;
                }
            }
        }
        
        private Vector3 CalculateCorrection(Vector3[] corners, Bounds bounds)
        {
            var minX = corners[0].x;
            var maxX = corners[0].x;
            var minZ = corners[0].z;
            var maxZ = corners[0].z;

            for (var i = 1; i < corners.Length; i++)
            {
                minX = Mathf.Min(minX, corners[i].x);
                maxX = Mathf.Max(maxX, corners[i].x);

                minZ = Mathf.Min(minZ, corners[i].z);
                maxZ = Mathf.Max(maxZ, corners[i].z);
            }

            var correction = Vector3.zero;

            var footprintWidth = maxX - minX;
            var footprintDepth = maxZ - minZ;

            var boundsWidth = bounds.max.x - bounds.min.x;
            var boundsDepth = bounds.max.z - bounds.min.z;

            // if camera is able to see more than the boundary, 
            // center it to avoid fighting between min/max.
            if (footprintWidth > boundsWidth)
            {
                var footprintCenterX = (minX + maxX) * 0.5f;
                correction.x = bounds.center.x - footprintCenterX;
            }
            else
            {
                if (minX < bounds.min.x)
                {
                    correction.x += bounds.min.x - minX;
                }

                if (maxX > bounds.max.x)
                {
                    correction.x -= maxX - bounds.max.x;
                }
            }

            if (footprintDepth > boundsDepth)
            {
                var footprintCenterZ = (minZ + maxZ) * 0.5f;
                correction.z = bounds.center.z - footprintCenterZ;
            }
            else
            {
                if (minZ < bounds.min.z)
                {
                    correction.z += bounds.min.z - minZ;
                }

                if (maxZ > bounds.max.z)
                {
                    correction.z -= maxZ - bounds.max.z;
                }
            }

            if (!confineX)
            {
                correction.x = 0f;
            }
            if (!confineZ)
            {
                correction.z = 0f;
            }
            correction.y = 0f;
            
            return correction;
        }

#if UNITY_EDITOR
        private void OnDrawGizmos()
        {
            var outputCamera = CameraSystem.Instance?.MainCamera;
            if (outputCamera is null) return;
            
            Gizmos.color = Color.yellow;
            var corners = new Vector3[4];
            if (!TryGetCameraGroundProjection(outputCamera, _currentProjectionY, corners))
            {
                return;
            }
            
            for (var i = 0; i < 4; i++)
            {
                var a = corners[i];
                var b = corners[(i + 1) % 4];

                Gizmos.DrawLine(a, b);
                Gizmos.DrawSphere(a, 0.15f);
            }
        }
        
        private static bool TryGetCameraGroundProjection(
            UnityEngine.Camera camera,
            float groundY,
            Vector3[] results)
        {
            var groundPlane = new Plane(Vector3.up, new Vector3(0f, groundY, 0f));
            Vector3[] viewportCorners =
            {
                new(0f, 0f, 0f), // bottom-left
                new(1f, 0f, 0f), // bottom-right
                new(1f, 1f, 0f), // top-right
                new(0f, 1f, 0f), // top-left
            };

            for (var i = 0; i < 4; i++)
            {
                var ray = camera.ViewportPointToRay(viewportCorners[i]);
                if (!groundPlane.Raycast(ray, out var distance))
                {
                    return false;
                }
                results[i] = ray.GetPoint(distance);
            }

            return true;
        }
#endif
    }
}