// Assets/_Project/Scripts/Gameplay/CameraController.cs
using UnityEngine;
using UnityEngine.InputSystem;
using TD.Core;
using TD.Levels;
using TD.UI;

namespace TD.Gameplay
{
    /// <summary>
    /// Per-client RTS-style camera controller. Lives on a "camera rig" GameObject (the pivot)
    /// with a child <see cref="Camera"/> GameObject. The pivot tracks the focus point on the
    /// buildable plane; the camera child orbits the pivot at a configurable pitch and dolly
    /// distance. Yaw is fixed.
    /// </summary>
    /// <remarks>
    /// <para><b>Rig math.</b> The pivot's world rotation is set to <c>(pitch, 0, 0)</c>. The
    /// camera child sits at local position <c>(0, 0, -dolly)</c> with identity local rotation.
    /// When the pivot rotates, the child's world position rotates with it, placing the camera
    /// up-and-back from the pivot at the right angle to look down at it. At pitch 0° the camera
    /// is horizontal (worm's-eye); at 90° it's directly overhead (top-down).</para>
    ///
    /// <para><b>Inputs.</b>
    /// <list type="bullet">
    ///   <item><b>WASD or Arrow keys</b> — pan the pivot across the buildable plane.</item>
    ///   <item><b>Mouse near screen edge</b> — edge-pan, when enabled.</item>
    ///   <item><b>Scroll wheel</b> — dolly zoom (cursor-anchored).</item>
    ///   <item><b>Alt + Scroll wheel</b> — adjust pitch.</item>
    /// </list></para>
    ///
    /// <para><b>Bounds clamping.</b> The pivot's tile is required to satisfy
    /// <see cref="LevelLoader.IsInMap"/>. When a frame's combined movement would leave the map,
    /// the controller tries each axis independently and applies whichever still lands in-map —
    /// producing wall-sliding behavior. If the loader isn't ready yet, clamping is skipped.</para>
    ///
    /// <para><b>Speed scaling.</b> Pan speed scales linearly with the current dolly distance, so
    /// zoomed-out panning covers more ground per second and zoomed-in panning is precise.</para>
    ///
    /// <para><b>Public API for minimap.</b> <see cref="JumpTo"/>, <see cref="BeginDrag"/>,
    /// <see cref="UpdateDrag"/>, <see cref="EndDrag"/> exist so the minimap (when implemented)
    /// can drive the camera without this controller knowing about it.</para>
    /// </remarks>
    public class CameraController : MonoBehaviour
    {
        // ----- Inspector --------------------------------------------------

        [Header("Rig")]
        [Tooltip("The camera GameObject that lives as a child of this rig pivot. " +
                 "Auto-found via GetComponentInChildren if left empty.")]
        [SerializeField] private Camera cameraChild;

        [Header("Pan")]
        [Tooltip("Base pan speed in world units per second at minimum zoom. " +
                 "Effective speed scales linearly with current dolly distance.")]
        [SerializeField] private float basePanSpeed = 12f;

        [Tooltip("Distance in pixels from each screen edge that triggers edge-pan. " +
                 "Smaller = needs precise mouse positioning; larger = activates more easily.")]
        [SerializeField] private float edgePanMarginPixels = 16f;

        [Tooltip("Whether edge-panning is enabled. Toggleable at runtime via " +
                 "EdgePanEnabled property — wire this to a settings menu when one exists.")]
        [SerializeField] private bool edgePanEnabled = true;

        [Header("Zoom")]
        [Tooltip("Closest zoom (smallest dolly distance from pivot to camera).")]
        [SerializeField] private float minDollyDistance = 5f;

        [Tooltip("Farthest zoom (largest dolly distance from pivot to camera).")]
        [SerializeField] private float maxDollyDistance = 50f;

        [Tooltip("Starting dolly distance at match start.")]
        [SerializeField] private float startDollyDistance = 20f;

        [Tooltip("Dolly distance change per scroll wheel tick (positive value; sign is " +
                 "applied based on scroll direction). Tuned for ±1-per-click scroll input. " +
                 "Adjust to taste.")]
        [SerializeField] private float zoomSpeed = 3f;

        [Tooltip("If true, zooming pulls the world toward the cursor — the screen point under " +
                 "the cursor stays roughly anchored to the same world point. If false, zoom " +
                 "centers on the screen midpoint.")]
        [SerializeField] private bool cursorAnchoredZoom = true;

        [Header("Pitch")]
        [Tooltip("Minimum pitch angle in degrees. 0° is horizontal; 90° is top-down.")]
        [SerializeField] private float minPitchDegrees = 30f;

        [Tooltip("Maximum pitch angle in degrees. 0° is horizontal; 90° is top-down.")]
        [SerializeField] private float maxPitchDegrees = 75f;

        [Tooltip("Starting pitch angle at match start.")]
        [SerializeField] private float startPitchDegrees = 60f;

        [Tooltip("Pitch change in degrees per Alt+Scroll tick. Tuned for ±1-per-click scroll " +
                 "input. Adjust to taste.")]
        [SerializeField] private float pitchSpeed = 4f;

        // ----- Runtime state ----------------------------------------------

        private float currentDolly;
        private float currentPitch;

        // True between BeginDrag and EndDrag (minimap drag mode). Suppresses normal
        // input handling so minimap-driven movement isn't fighting keyboard/edge-pan.
        private bool isExternalDragActive;

        // ----- Public API -------------------------------------------------

        /// <summary>Whether edge-panning is currently active. Wire to settings UI.</summary>
        public bool EdgePanEnabled
        {
            get => edgePanEnabled;
            set => edgePanEnabled = value;
        }

        /// <summary>Snaps the pivot to <paramref name="worldPos"/>. Y is ignored. Used by the
        /// minimap for click-to-jump.</summary>
        public void JumpTo(Vector3 worldPos)
        {
            Vector3 newPivot = new Vector3(worldPos.x, GridCoordinates.BUILDABLE_PLANE_Y, worldPos.z);
            TryMovePivotTo(newPivot);
            ApplyTransform();
        }

        /// <summary>Begins external drag mode (e.g., from the minimap). Subsequent
        /// <see cref="UpdateDrag"/> calls drive the pivot directly, bypassing keyboard/edge
        /// input until <see cref="EndDrag"/>.</summary>
        public void BeginDrag() => isExternalDragActive = true;

        /// <summary>Updates the pivot to the dragged world position. Same effect as
        /// <see cref="JumpTo"/> but intended to be called every frame during a drag.</summary>
        public void UpdateDrag(Vector3 worldPos) => JumpTo(worldPos);

        /// <summary>Ends external drag mode. Normal input handling resumes.</summary>
        public void EndDrag() => isExternalDragActive = false;

        /// <summary>
        /// Computes where the four screen corners project onto the buildable plane and
        /// writes them into <paramref name="cornersOut"/> in the order BL, BR, TR, TL.
        /// Returns true when every ray hits the plane in front of the camera. When the
        /// camera angles above the horizon for one or more corners (rare in our pitch
        /// range), those corner(s) fall back to a far point along the ray and the method
        /// returns false — the caller may still use the result; the off-plane corners
        /// just sit far outside the map. Used by the minimap to draw the viewport
        /// trapezoid (the "what the player sees" rectangle).
        /// </summary>
        public bool TryGetViewportWorldCorners(Vector3[] cornersOut)
        {
            if (cornersOut == null || cornersOut.Length < 4) return false;
            if (cameraChild == null) return false;

            // Buildable plane is Y = BUILDABLE_PLANE_Y, normal = up.
            var plane = new Plane(Vector3.up,
                new Vector3(0f, GridCoordinates.BUILDABLE_PLANE_Y, 0f));

            // Screen-space order: BL, BR, TR, TL (origin at bottom-left, Y up).
            // Resulting world points form a trapezoid on the buildable plane (camera
            // is angled, so the far edge — top of screen — projects wider than the
            // near edge — bottom of screen).
            float w = Screen.width;
            float h = Screen.height;
            cornersOut[0] = ProjectScreenToPlane(new Vector2(0f, 0f), plane, out bool a);
            cornersOut[1] = ProjectScreenToPlane(new Vector2(w,  0f), plane, out bool b);
            cornersOut[2] = ProjectScreenToPlane(new Vector2(w,  h ), plane, out bool c);
            cornersOut[3] = ProjectScreenToPlane(new Vector2(0f, h ), plane, out bool d);
            return a && b && c && d;
        }

        // Helper: ray from screen point, intersect plane. If it doesn't hit in front of
        // the camera (rare — only at horizon-or-above pitches), use a far fallback so
        // the caller still gets a usable value.
        private Vector3 ProjectScreenToPlane(Vector2 screenPoint, Plane plane, out bool hit)
        {
            Ray ray = cameraChild.ScreenPointToRay(new Vector3(screenPoint.x, screenPoint.y, 0f));
            if (plane.Raycast(ray, out float dist) && dist > 0f)
            {
                hit = true;
                return ray.GetPoint(dist);
            }
            hit = false;
            // Far point along the ray as a graceful fallback. Distance picked large enough
            // that the resulting UI coord lands well outside the minimap bounds and gets
            // clipped by overflow:hidden.
            return ray.GetPoint(1000f);
        }

        // ----- Lifecycle --------------------------------------------------

        private void Start()
        {
            if (cameraChild == null) cameraChild = GetComponentInChildren<Camera>();
            if (cameraChild == null)
            {
                Debug.LogError("[CameraController] No child Camera found. Place a Camera as " +
                               "a child of this GameObject or assign it in the inspector.");
                enabled = false;
                return;
            }

            currentDolly = Mathf.Clamp(startDollyDistance, minDollyDistance, maxDollyDistance);
            currentPitch = Mathf.Clamp(startPitchDegrees, minPitchDegrees, maxPitchDegrees);

            // Compute initial pivot position. Falls back to current transform if loader or
            // local-player data isn't ready (e.g., editor preview before Start Host).
            Vector3 startPivot = ComputeStartPivot(transform.position);
            transform.position = startPivot;

            ApplyTransform();
        }

        private void Update()
        {
            if (isExternalDragActive)
            {
                // Minimap or other external system is driving — don't fight it.
                return;
            }

            HandleZoomAndPitch();
            HandlePan();
        }

        // ----- Input handlers ---------------------------------------------

        private void HandlePan()
        {
            Vector2 dir = Vector2.zero;

            // Keyboard: WASD + arrow keys. Suppressed entirely while the player
            // is typing — pressing 'a' or 'w' into chat should not pan the camera.
            // (Edge-pan below stays active since it's mouse-driven.)
            var kb = HUDController.IsTextInputActive ? null : Keyboard.current;
            if (kb != null)
            {
                if (kb.aKey.isPressed || kb.leftArrowKey.isPressed) dir.x -= 1f;
                if (kb.dKey.isPressed || kb.rightArrowKey.isPressed) dir.x += 1f;
                if (kb.sKey.isPressed || kb.downArrowKey.isPressed) dir.y -= 1f;
                if (kb.wKey.isPressed || kb.upArrowKey.isPressed) dir.y += 1f;
            }

            // Edge-pan: mouse near screen edge adds to keyboard direction.
            if (edgePanEnabled && Mouse.current != null)
            {
                Vector2 mousePos = Mouse.current.position.ReadValue();
                if (mousePos.x <= edgePanMarginPixels) dir.x -= 1f;
                if (mousePos.x >= Screen.width - edgePanMarginPixels) dir.x += 1f;
                if (mousePos.y <= edgePanMarginPixels) dir.y -= 1f;
                if (mousePos.y >= Screen.height - edgePanMarginPixels) dir.y += 1f;
            }

            if (dir.sqrMagnitude < 0.0001f) return;

            // Normalize to prevent diagonal speed boost when both axes active.
            if (dir.sqrMagnitude > 1f) dir.Normalize();

            // Scale speed by current zoom: at min zoom multiplier=1, at max zoom multiplier
            // is the ratio of max-to-min dolly distance.
            float speedMultiplier = currentDolly / minDollyDistance;
            float distance = basePanSpeed * speedMultiplier * Time.deltaTime;

            // dir.x maps to world X, dir.y maps to world Z (since pivot is on XZ plane).
            Vector3 desired = transform.position + new Vector3(dir.x, 0f, dir.y) * distance;
            TryMovePivotTo(desired);
            ApplyTransform();
        }

        private void HandleZoomAndPitch()
        {
            var mouse = Mouse.current;
            var kb = Keyboard.current;
            if (mouse == null) return;

            // If the cursor is over an interactive HUD element (e.g., the minimap, which has
            // its own scroll-wheel zoom), don't also drive the world camera. UI Toolkit's
            // WheelEvent.StopPropagation only blocks UI-side bubbling — it has no effect on
            // raw Input System polling, so we have to gate here explicitly.
            if (HUDController.IsPointerOverInteractiveHud(mouse.position.ReadValue())) return;

            // Scroll wheel values are inconsistent across platforms and devices:
            //   - Windows click-wheel mice: ±120 per click (legacy Win32 convention)
            //   - Free-spin wheels and trackpads: small fractional values per tick
            //   - macOS / Linux mice: typically ±1 per click
            // We use the raw value with no normalization. Speed defaults are tuned for
            // ±1-per-click hardware (Steam Deck, macOS, many Linux/Windows mice). On
            // ±120-per-click hardware, scroll naturally feels faster — which usually
            // matches the "discrete tick" feel users expect from those mice anyway.
            float scrollDelta = mouse.scroll.ReadValue().y;
            if (Mathf.Abs(scrollDelta) < 0.0001f) return;

            bool altHeld = kb != null &&
                           (kb.leftAltKey.isPressed || kb.rightAltKey.isPressed);

            if (altHeld)
            {
                // Alt + scroll: adjust pitch.
                currentPitch = Mathf.Clamp(currentPitch - scrollDelta * pitchSpeed,
                                           minPitchDegrees, maxPitchDegrees);
                ApplyTransform();
            }
            else
            {
                // Plain scroll: dolly zoom. Negative scroll = zoom out.
                ApplyZoom(scrollDelta * zoomSpeed, mouse.position.ReadValue());
            }
        }

        // ----- Zoom with cursor anchor ------------------------------------

        private void ApplyZoom(float zoomDelta, Vector2 cursorScreen)
        {
            if (!cursorAnchoredZoom)
            {
                currentDolly = Mathf.Clamp(currentDolly - zoomDelta,
                                           minDollyDistance, maxDollyDistance);
                ApplyTransform();
                return;
            }

            // Cursor-anchored zoom:
            //   1. Find the world point under the cursor BEFORE the zoom.
            //   2. Apply zoom (changes dolly).
            //   3. Find the world point under the cursor AFTER the zoom.
            //   4. Translate the pivot by (before - after) so the cursor's world point stays put.

            if (!TryGroundPlanePoint(cursorScreen, out Vector3 before))
            {
                // Cursor not over the ground plane — fall back to non-anchored zoom.
                currentDolly = Mathf.Clamp(currentDolly - zoomDelta,
                                           minDollyDistance, maxDollyDistance);
                ApplyTransform();
                return;
            }

            currentDolly = Mathf.Clamp(currentDolly - zoomDelta,
                                       minDollyDistance, maxDollyDistance);
            ApplyTransform();

            if (!TryGroundPlanePoint(cursorScreen, out Vector3 after))
            {
                return;
            }

            Vector3 desiredPivot = transform.position + (before - after);
            TryMovePivotTo(desiredPivot);
            ApplyTransform();
        }

        // ----- Pivot movement with bounds clamping ------------------------

        /// <summary>
        /// Attempts to move the pivot to <paramref name="desired"/>. If the new position would
        /// leave the map, tries each axis independently and applies whichever still lands in
        /// the map (wall-sliding). If neither axis works, the pivot doesn't move.
        /// </summary>
        private void TryMovePivotTo(Vector3 desired)
        {
            // If LevelLoader isn't loaded, accept the move as-is (editor preview, early frames).
            var loader = LevelLoader.Instance;
            if (loader == null || !loader.IsLoaded)
            {
                transform.position = new Vector3(desired.x, GridCoordinates.BUILDABLE_PLANE_Y, desired.z);
                return;
            }

            Vector3 current = transform.position;

            if (IsPositionInMap(loader, desired))
            {
                transform.position = new Vector3(desired.x, GridCoordinates.BUILDABLE_PLANE_Y, desired.z);
                return;
            }

            // Try X-only move (slide along the Z wall).
            Vector3 xOnly = new Vector3(desired.x, current.y, current.z);
            if (IsPositionInMap(loader, xOnly))
            {
                transform.position = new Vector3(xOnly.x, GridCoordinates.BUILDABLE_PLANE_Y, xOnly.z);
                return;
            }

            // Try Z-only move (slide along the X wall).
            Vector3 zOnly = new Vector3(current.x, current.y, desired.z);
            if (IsPositionInMap(loader, zOnly))
            {
                transform.position = new Vector3(zOnly.x, GridCoordinates.BUILDABLE_PLANE_Y, zOnly.z);
                return;
            }

            // Both axes blocked — don't move.
        }

        private static bool IsPositionInMap(LevelLoader loader, Vector3 worldPos)
        {
            Vector2Int tile = GridCoordinates.WorldToGrid(worldPos);
            return loader.IsInMap(tile);
        }

        // ----- Transform composition --------------------------------------

        /// <summary>
        /// Applies <see cref="currentPitch"/> to the pivot rotation and
        /// <see cref="currentDolly"/> to the camera's local Z offset. Yaw stays at 0.
        /// </summary>
        private void ApplyTransform()
        {
            transform.rotation = Quaternion.Euler(currentPitch, 0f, 0f);
            cameraChild.transform.localPosition = new Vector3(0f, 0f, -currentDolly);
            cameraChild.transform.localRotation = Quaternion.identity;
        }

        // ----- Ground-plane raycast helper --------------------------------

        /// <summary>
        /// Casts a ray from the camera through <paramref name="screenPos"/> against the
        /// buildable plane (Y = <see cref="GridCoordinates.BUILDABLE_PLANE_Y"/>). Returns
        /// false if the ray is parallel to the plane or the cursor is above the horizon.
        /// </summary>
        private bool TryGroundPlanePoint(Vector2 screenPos, out Vector3 worldPos)
        {
            worldPos = default;
            Ray ray = cameraChild.ScreenPointToRay(new Vector3(screenPos.x, screenPos.y, 0f));

            Plane ground = new Plane(Vector3.up, new Vector3(0f, GridCoordinates.BUILDABLE_PLANE_Y, 0f));
            if (!ground.Raycast(ray, out float enter)) return false;

            worldPos = ray.GetPoint(enter);
            return true;
        }

        // ----- Initial position -------------------------------------------

        /// <summary>
        /// Computes the initial pivot position as the centroid of the local player's owned
        /// tiles. Falls back to the map center, and finally to <paramref name="fallback"/>
        /// (the rig's authoring-time position) if neither is available yet.
        /// </summary>
        private Vector3 ComputeStartPivot(Vector3 fallback)
        {
            var loader = LevelLoader.Instance;
            if (loader == null || !loader.IsLoaded) return fallback;

            PlayerSlot localSlot = GetLocalPlayerSlot();

            if (localSlot != PlayerSlot.None)
            {
                if (TryComputeZoneCentroid(loader, localSlot, out Vector3 centroid))
                    return centroid;
            }

            // Fall back to map center.
            return ComputeMapCenter(loader.LevelData);
        }

        private static bool TryComputeZoneCentroid(LevelLoader loader, PlayerSlot slot,
                                                   out Vector3 centroid)
        {
            centroid = default;

            var levelData = loader.LevelData;
            if (levelData == null || levelData.OwnerGrid == null) return false;

            // Sum the world positions of every tile owned by this slot.
            // Using Vector2 sums to avoid Y precision drift.
            Vector2 sum = Vector2.zero;
            int count = 0;

            for (int y = 0; y < levelData.GridSize.y; y++)
            {
                for (int x = 0; x < levelData.GridSize.x; x++)
                {
                    int idx = y * levelData.GridSize.x + x;
                    if (levelData.OwnerGrid[idx] != slot) continue;

                    Vector2Int tile = new Vector2Int(
                        levelData.GridOriginTile.x + x,
                        levelData.GridOriginTile.y + y);
                    Vector3 world = GridCoordinates.GridToWorld(tile);
                    sum.x += world.x;
                    sum.y += world.z;
                    count++;
                }
            }

            if (count == 0) return false;

            centroid = new Vector3(sum.x / count, GridCoordinates.BUILDABLE_PLANE_Y, sum.y / count);
            return true;
        }

        private static Vector3 ComputeMapCenter(LevelData levelData)
        {
            if (levelData == null) return Vector3.zero;
            float cx = (levelData.GridOriginTile.x + (levelData.GridSize.x - 1) * 0.5f)
                     * GridCoordinates.TILE_SIZE;
            float cz = (levelData.GridOriginTile.y + (levelData.GridSize.y - 1) * 0.5f)
                     * GridCoordinates.TILE_SIZE;
            return new Vector3(cx, GridCoordinates.BUILDABLE_PLANE_Y, cz);
        }

        // ----- Player slot ------------------------------------------------

        private static PlayerSlot GetLocalPlayerSlot()
            => PlayerMatchState.Local?.Slot ?? PlayerSlot.None;
    }
}