using System;
using System.Collections.Generic;
using Intrepid.Diagnostics;
using Intrepid.Utilities;
using Sirenix.OdinInspector;
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif

namespace Intrepid.Gameplay.Journey
{
    [Serializable]
    [CreateAssetMenu(fileName = "Journey Service", menuName = "GON/Services/Journey Service")]
    public class JourneyService : SingletonScriptableObject<JourneyService>
    {
        private const string WrongEndpoint = "-- WRONG ENDPOINT --";
        private const float WorldTileSize = 24f;
        private const float TileEpsilon = 0.0001f;

        [GroupInjections, SerializeField] private JGraphSO journeyGraph;

        [GroupInjections, SerializeField, ReadOnly, ListDrawerSettings(ShowFoldout = false)]
        private List<StageTag> stagesTags = new();

        private Dictionary<StageTag, JStage> StageMap { get; } = new();
        private Dictionary<string, StageTag> StageTagMap { get; } = new();
        private Dictionary<string, JEndpoint> EndpointMap { get; } = new();
        private Dictionary<string, StageTag> EndpointStageTagMap { get; } = new();
        private Dictionary<EndpointLookupKey, string> EndpointLocationMap { get; } = new();
        private Dictionary<string, Dictionary<string, List<JConnection>>> StageConnections { get; } = new();

        public IReadOnlyCollection<StageTag> StageTags => StageTagMap.Values;

#if UNITY_EDITOR
        [PropertyOrder(-10)]
        [GroupInjections, Button(ButtonSizes.Large), GUIColor(0.35f, 0.8f, .35f)]
        protected void FindAllStageTags()
        {
            stagesTags ??= new List<StageTag>();
            stagesTags.Clear();

            var guids = AssetDatabase.FindAssets("t:StageTag");

            foreach (var guid in guids)
            {
                var path = AssetDatabase.GUIDToAssetPath(guid);
                var stageTag = AssetDatabase.LoadAssetAtPath<StageTag>(path);

                if (stageTag.IsNull())
                {
                    continue;
                }

                stagesTags.Add(stageTag);
            }

            EditorUtility.SetDirty(this);
        }
#endif

        protected override void WhenInstantiate(JourneyService singleton)
        {
            base.WhenInstantiate(singleton);
            LoadCaches();
        }

        [GroupDebug, Button(ButtonSizes.Medium)]
        private void ValidateStages()
        {
            if (journeyGraph.IsNull())
            {
                DLogger.LogError($"{nameof(journeyGraph)} should not be null or empty");
                return;
            }

            if (stagesTags.IsNull() || stagesTags.Count == 0)
            {
                DLogger.LogError($"{nameof(stagesTags)} should not be null or empty");
                return;
            }

            var stageTagMap = BuildStageTagMap();

            foreach (var jStage in journeyGraph.stages)
            {
                if (!stageTagMap.TryGetValue(jStage.id, out var tag))
                {
                    DLogger.LogDebug($"Stage {jStage.id} is not found in the stage tag list");
                    continue;
                }

                tag.TestIfExists();
            }
        }

        [GroupDebug, Button(ButtonSizes.Medium)]
        private void LoadCaches()
        {
            if (journeyGraph.IsNull())
            {
                throw new NullReferenceException($"{nameof(journeyGraph)} should not be null or empty");
            }

            ClearRuntimeCache();

            BuildStageTagCache();
            BuildEndpointCache();
            BuildStageConnectionCache();
            BuildStageMapCache();
        }

        private void ClearRuntimeCache()
        {
            StageMap.Clear();
            StageTagMap.Clear();
            EndpointMap.Clear();
            EndpointStageTagMap.Clear();
            EndpointLocationMap.Clear();
            StageConnections.Clear();
        }

        private Dictionary<string, StageTag> BuildStageTagMap()
        {
            var map = new Dictionary<string, StageTag>();

            if (stagesTags.IsNull())
            {
                return map;
            }

            foreach (var stageTag in stagesTags)
            {
                if (stageTag.IsNull())
                {
                    continue;
                }

                if (string.IsNullOrWhiteSpace(stageTag.StageId))
                {
                    DLogger.LogWarn($"StageTag {stageTag.name} has null or empty StageId");
                    continue;
                }

                if (!map.TryAdd(stageTag.StageId, stageTag))
                {
                    DLogger.LogWarn($"Duplicated StageTag for StageId {stageTag.StageId}");
                }
            }

            return map;
        }

        private void BuildStageTagCache()
        {
            var map = BuildStageTagMap();

            foreach (var pair in map)
            {
                StageTagMap.Add(pair.Key, pair.Value);
            }
        }

        private void BuildEndpointCache()
        {
            foreach (var endpoint in journeyGraph.endpoints)
            {
                if (endpoint.IsNull())
                {
                    continue;
                }

                if (string.IsNullOrWhiteSpace(endpoint.id))
                {
                    DLogger.LogWarn("Endpoint with null or empty id found");
                    continue;
                }

                if (!EndpointMap.TryAdd(endpoint.id, endpoint))
                {
                    DLogger.LogWarn($"Duplicated endpoint id {endpoint.id}");
                    continue;
                }

                var lookupKey = new EndpointLookupKey(
                    endpoint.stageId,
                    endpoint.worldTile,
                    endpoint.socketFace
                );

                if (!EndpointLocationMap.TryAdd(lookupKey, endpoint.id))
                {
                    DLogger.LogWarn($"Duplicated endpoint location {lookupKey}");
                }

                EnsureStageConnectionBucket(endpoint.stageId, endpoint.id);

                if (StageTagMap.TryGetValue(endpoint.stageId, out var stageTag))
                {
                    EndpointStageTagMap.TryAdd(endpoint.id, stageTag);
                }
                else
                {
                    DLogger.LogWarn($"Endpoint {endpoint.id} references stage {endpoint.stageId}, but no StageTag was found");
                }
            }
        }

        private void BuildStageConnectionCache()
        {
            foreach (var connection in journeyGraph.connections)
            {
                if (connection.IsNull())
                {
                    continue;
                }

                if (!EndpointMap.TryGetValue(connection.endpointAId, out var endpointA))
                {
                    DLogger.LogWarn($"Connection {connection.id} has invalid endpoint A: {connection.endpointAId}");
                    continue;
                }

                if (!EndpointMap.TryGetValue(connection.endpointBId, out var endpointB))
                {
                    DLogger.LogWarn($"Connection {connection.id} has invalid endpoint B: {connection.endpointBId}");
                    continue;
                }

                StageConnections[endpointA.stageId][endpointA.id].Add(connection);
                StageConnections[endpointB.stageId][endpointB.id].Add(connection);
            }
        }

        private void BuildStageMapCache()
        {
            foreach (var jStage in journeyGraph.stages)
            {
                if (jStage.IsNull())
                {
                    continue;
                }

                if (StageTagMap.TryGetValue(jStage.id, out var tag))
                {
                    StageMap.TryAdd(tag, jStage);
                }
                else
                {
                    DLogger.LogWarn($"Stage {jStage.id} is not found in the stage tag list");
                }
            }
        }

        private void EnsureStageConnectionBucket(string stageId, string endpointId)
        {
            if (!StageConnections.TryGetValue(stageId, out var stageConnections))
            {
                stageConnections = new Dictionary<string, List<JConnection>>();
                StageConnections[stageId] = stageConnections;
            }

            stageConnections.TryAdd(endpointId, new List<JConnection>());
        }

        public bool TryGetJConnections(
            string stageId,
            string endpointId,
            out IReadOnlyList<JConnection> jConnections)
        {
            if (StageConnections.TryGetValue(stageId, out var stageConnections) &&
                stageConnections.TryGetValue(endpointId, out var connections))
            {
                jConnections = connections;
                return true;
            }

            jConnections = Array.Empty<JConnection>();
            return false;
        }

        public bool TryGetStageTagFromEndpoint(string endpointId, out StageTag stageTag)
        {
            return EndpointStageTagMap.TryGetValue(endpointId, out stageTag);
        }

        public bool TryGetEndpoint(string endpointId, out JEndpoint endpoint)
        {
            return EndpointMap.TryGetValue(endpointId, out endpoint);
        }

        public bool TryGetJStage(StageTag stageTag, out JStage jStage)
        {
            return StageMap.TryGetValue(stageTag, out jStage);
        }

        public bool TryGetWorldPoint(
            StageTag stageTag,
            Vector3 localPosition,
            out Vector2Int worldPoint)
        {
            worldPoint = default;

            if (!TryGetJStageFromTag(stageTag, out var jStage))
            {
                return false;
            }

            if (!TryGetOccupiedTileBounds(jStage, out var min, out var max))
            {
                DLogger.LogWarn($"Stage {jStage.id} has no occupied tiles");
                return false;
            }

            var stageSize = new Vector2Int(
                max.x - min.x + 1,
                max.y - min.y + 1
            );

            var localOffset = LocalPositionToStageTileOffset(
                localPosition,
                stageSize
            );

            worldPoint = min + localOffset;
            return true;
        }

        public bool TryGetWorldPoint(
            StageTag stageTag,
            Vector3 localPosition,
            JSocketFace face,
            out Vector2Int worldPoint)
        {
            worldPoint = default;

            if (!TryGetJStageFromTag(stageTag, out var jStage))
            {
                return false;
            }

            if (!TryGetOccupiedTileBounds(jStage, out var min, out var max))
            {
                DLogger.LogWarn($"Stage {jStage.id} has no occupied tiles");
                return false;
            }

            var stageSize = new Vector2Int(
                max.x - min.x + 1,
                max.y - min.y + 1
            );

            var localOffset = LocalPositionToEndpointOwnerTileOffset(
                localPosition,
                stageSize,
                face
            );

            worldPoint = min + localOffset;
            return true;
        }

        public bool TryGetEndpoint(
            StageTag stageTag,
            JWorldTile worldTile,
            JSocketFace face,
            out string endpointId)
        {
            endpointId = WrongEndpoint;

            if (!TryGetJStageFromTag(stageTag, out var jStage))
            {
                return false;
            }

            var lookupKey = new EndpointLookupKey(
                jStage.id,
                worldTile,
                face
            );

            if (EndpointLocationMap.TryGetValue(lookupKey, out endpointId))
            {
                return true;
            }

            DLogger.LogWarn($"No endpoint found for {lookupKey}");
            return false;
        }

        public bool TryGetEndpoint(
            StageTag stageTag,
            Vector3 localPosition,
            JSocketFace face,
            out string endpointId)
        {
            endpointId = WrongEndpoint;

            if (!TryGetWorldPoint(stageTag, localPosition, face, out var worldPoint))
            {
                return false;
            }

            var worldTile = new JWorldTile(worldPoint.x, worldPoint.y);
            return TryGetEndpoint(stageTag, worldTile, face, out endpointId);
        }

        public string GetEndpoint(
            StageTag stageTag,
            JWorldTile worldTile,
            JSocketFace face)
        {
            return TryGetEndpoint(stageTag, worldTile, face, out var endpointId)
                ? endpointId
                : WrongEndpoint;
        }

        public string GetEndpoint(
            StageTag stageTag,
            Vector3 localPosition,
            JSocketFace face)
        {
            return TryGetEndpoint(stageTag, localPosition, face, out var endpointId)
                ? endpointId
                : WrongEndpoint;
        }

        private bool TryGetJStageFromTag(StageTag stageTag, out JStage jStage)
        {
            jStage = null;

            if (stageTag.IsNull())
            {
                DLogger.LogWarn($"{nameof(stageTag)} should not be null");
                return false;
            }

            if (!StageMap.TryGetValue(stageTag, out jStage) || jStage.IsNull())
            {
                DLogger.LogWarn($"StageTag {stageTag.name} was not found in the stage map");
                return false;
            }

            return true;
        }

        private static bool TryGetOccupiedTileBounds(
            JStage stage,
            out Vector2Int min,
            out Vector2Int max)
        {
            min = default;
            max = default;

            if (stage.IsNull() ||
                stage.occupiedTiles.IsNull() ||
                stage.occupiedTiles.Count == 0)
            {
                return false;
            }

            min = stage.occupiedTiles[0].ToVector2Int();
            max = stage.occupiedTiles[0].ToVector2Int();

            for (var i = 1; i < stage.occupiedTiles.Count; i++)
            {
                var tile = stage.occupiedTiles[i];

                min = new Vector2Int(
                    Mathf.Min(min.x, tile.x),
                    Mathf.Min(min.y, tile.y)
                );

                max = new Vector2Int(
                    Mathf.Max(max.x, tile.x),
                    Mathf.Max(max.y, tile.y)
                );
            }

            return true;
        }

        private static Vector2Int LocalPositionToStageTileOffset(
            Vector3 localPosition,
            Vector2Int stageSize)
        {
            return new Vector2Int(
                LocalAxisToStageTileOffset(localPosition.x, stageSize.x),

                // Journey/world-layout Y grows downward in the editor.
                // Unity local Z grows forward/up in the scene.
                // So we invert Z when converting to Journey Y.
                LocalAxisToStageTileOffset(-localPosition.z, stageSize.y)
            );
        }

        private static int LocalAxisToStageTileOffset(
            float localValue,
            int occupiedTileCount)
        {
            var stageSizeInMeters = occupiedTileCount * WorldTileSize;
            var halfStageSizeInMeters = stageSizeInMeters * 0.5f;

            // The baked stage is centered in the scene.
            // Convert local coordinate from center-origin to min-tile-origin.
            var valueFromMin = localValue + halfStageSizeInMeters;

            if (valueFromMin <= TileEpsilon)
            {
                return -1;
            }

            if (valueFromMin >= stageSizeInMeters - TileEpsilon)
            {
                return occupiedTileCount;
            }

            return Mathf.FloorToInt(valueFromMin / WorldTileSize);
        }

        private static Vector2Int LocalPositionToEndpointOwnerTileOffset(
            Vector3 localPosition,
            Vector2Int stageSize,
            JSocketFace face)
        {
            var x = face switch
            {
                JSocketFace.East => LocalAxisToEndpointOwnerTileOffset(
                    localPosition.x,
                    stageSize.x,
                    positiveFace: true),

                JSocketFace.West => LocalAxisToEndpointOwnerTileOffset(
                    localPosition.x,
                    stageSize.x,
                    positiveFace: false),

                _ => LocalAxisToOwnedTileOffset(
                    localPosition.x,
                    stageSize.x)
            };

            // Journey/world-layout Y grows downward.
            // Unity local Z grows forward/up.
            // So Journey local Y = -local Z.
            var localY = -localPosition.z;

            var y = face switch
            {
                JSocketFace.South => LocalAxisToEndpointOwnerTileOffset(
                    localY,
                    stageSize.y,
                    positiveFace: true),

                JSocketFace.North => LocalAxisToEndpointOwnerTileOffset(
                    localY,
                    stageSize.y,
                    positiveFace: false),

                _ => LocalAxisToOwnedTileOffset(
                    localY,
                    stageSize.y)
            };

            return new Vector2Int(x, y);
        }

        private static int LocalAxisToOwnedTileOffset(
            float localValue,
            int occupiedTileCount)
        {
            var stageSizeInMeters = occupiedTileCount * WorldTileSize;
            var halfStageSizeInMeters = stageSizeInMeters * 0.5f;

            var valueFromMin = localValue + halfStageSizeInMeters;

            var offset = Mathf.FloorToInt(valueFromMin / WorldTileSize);

            return Mathf.Clamp(
                offset,
                0,
                occupiedTileCount - 1
            );
        }

        private static int LocalAxisToEndpointOwnerTileOffset(
            float localValue,
            int occupiedTileCount,
            bool positiveFace)
        {
            var stageSizeInMeters = occupiedTileCount * WorldTileSize;
            var halfStageSizeInMeters = stageSizeInMeters * 0.5f;

            var valueFromMin = localValue + halfStageSizeInMeters;

            int offset;

            if (positiveFace)
            {
                // East/South face:
                // the boundary belongs to the tile before the boundary.
                //
                // Example:
                // valueFromMin = 24 on a 1-tile-wide stage
                // should resolve to offset 0, not 1.
                offset = Mathf.CeilToInt(
                    (valueFromMin - TileEpsilon) / WorldTileSize
                ) - 1;
            }
            else
            {
                // West/North face:
                // the boundary belongs to the tile after the boundary.
                //
                // Example:
                // valueFromMin = 0 on a 1-tile-wide stage
                // should resolve to offset 0, not -1.
                offset = Mathf.FloorToInt(
                    (valueFromMin + TileEpsilon) / WorldTileSize
                );
            }

            return Mathf.Clamp(
                offset,
                0,
                occupiedTileCount - 1
            );
        }

        #region EndpointLookupKey

        private readonly struct EndpointLookupKey : IEquatable<EndpointLookupKey>
        {
            private readonly string _stageId;
            private readonly int _x;
            private readonly int _y;
            private readonly JSocketFace _face;

            public EndpointLookupKey(
                string stageId,
                JWorldTile worldTile,
                JSocketFace face)
            {
                _stageId = stageId;
                _x = worldTile.x;
                _y = worldTile.y;
                _face = face;
            }

            public bool Equals(EndpointLookupKey other)
            {
                return _stageId == other._stageId &&
                       _x == other._x &&
                       _y == other._y &&
                       _face == other._face;
            }

            public override bool Equals(object obj)
            {
                return obj is EndpointLookupKey other && Equals(other);
            }

            public override int GetHashCode()
            {
                return HashCode.Combine(_stageId, _x, _y, _face);
            }

            public override string ToString()
            {
                return $"{_stageId} @ ({_x}, {_y}) / {_face}";
            }
        }

        #endregion
    }
}