using FishNet.Connection;
using FishNet.Managing.Debugging;
using FishNet.Managing.Logging;
using FishNet.Managing.Server;
using FishNet.Managing.Timing;
using FishNet.Managing.Transporting;
using FishNet.Serializing;
using FishNet.Transporting;
using FishNet.Transporting.Multipass;
using GameKit.Utilities;
using System;
using System.Collections.Generic;
using UnityEngine;

namespace FishNet.Managing.Client
{
    /// <summary>
    /// A container for local client data and actions.
    /// </summary>
    [DisallowMultipleComponent]
    [AddComponentMenu("FishNet/Manager/ClientManager")]
    public sealed partial class ClientManager : MonoBehaviour
    {
        #region Public.
        /// <summary>
        /// Called after local client has authenticated.
        /// </summary>
        public event Action OnAuthenticated;
        /// <summary>
        /// Called when the local client connection to the server has timed out.
        /// This is called immediately before disconnecting.
        /// </summary>
        public event Action OnClientTimeOut;
        /// <summary>
        /// Called after the local client connection state changes.
        /// </summary>
        public event Action<ClientConnectionStateArgs> OnClientConnectionState;
        /// <summary>
        /// Called when a client other than self connects.
        /// This is only available when using ServerManager.ShareIds.
        /// </summary>
        public event Action<RemoteConnectionStateArgs> OnRemoteConnectionState;
        /// <summary>
        /// Called when the server sends all currently connected clients.
        /// This is only available when using ServerManager.ShareIds.
        /// </summary>
        public event Action<ConnectedClientsArgs> OnConnectedClients;
        /// <summary>
        /// True if the client connection is connected to the server.
        /// </summary>
        public bool Started { get; private set; }
        /// <summary>
        /// NetworkConnection the local client is using to send data to the server.
        /// </summary>
        public NetworkConnection Connection = NetworkManager.EmptyConnection;
        /// <summary>
        /// Handling and information for objects known to the local client.
        /// </summary>
        public ClientObjects Objects { get; private set; }
        /// <summary>
        /// All currently connected clients. This field only contains data while ServerManager.ShareIds is enabled.
        /// </summary>
        public Dictionary<int, NetworkConnection> Clients = new Dictionary<int, NetworkConnection>();
        /// <summary>
        /// NetworkManager for client.
        /// </summary>
        [HideInInspector]
        public NetworkManager NetworkManager { get; private set; }
        #endregion

        #region Serialized.
        /// <summary>
        /// What platforms to enable remote server timeout.
        /// </summary>
        [Tooltip("What platforms to enable remote server timeout.")]
        [SerializeField]
        private RemoteTimeoutType _remoteServerTimeout = RemoteTimeoutType.Development;
        /// <summary>
        /// How long in seconds server must go without sending any packets before the local client disconnects. This is independent of any transport settings.
        /// </summary>
        [Tooltip("How long in seconds server must go without sending any packets before the local client disconnects. This is independent of any transport settings.")]
        [Range(1, ServerManager.MAXIMUM_REMOTE_CLIENT_TIMEOUT_DURATION)]
        [SerializeField]
        private ushort _remoteServerTimeoutDuration = 60;
        /// <summary>
        /// Sets timeout settings. Can be used at runtime.
        /// </summary>
        /// <returns></returns>
        public void SetRemoteServerTimeout(RemoteTimeoutType timeoutType, ushort duration)
        {
            _remoteServerTimeout = timeoutType;
            duration = (ushort)Mathf.Clamp(duration, 1, ServerManager.MAXIMUM_REMOTE_CLIENT_TIMEOUT_DURATION);
            _remoteServerTimeoutDuration = duration;
        }
        //todo add remote server timeout (see ServerManager.RemoteClientTimeout).
        /// <summary>
        /// True to automatically set the frame rate when the client connects.
        /// </summary>
        [Tooltip("True to automatically set the frame rate when the client connects.")]
        [SerializeField]
        private bool _changeFrameRate = true;
        /// <summary>
        /// 
        /// </summary>
        [Tooltip("Maximum frame rate the client may run at. When as host this value runs at whichever is higher between client and server.")]
        [Range(1, NetworkManager.MAXIMUM_FRAMERATE)]
        [SerializeField]
        private ushort _frameRate = NetworkManager.MAXIMUM_FRAMERATE;
        /// <summary>
        /// Maximum frame rate the client may run at. When as host this value runs at whichever is higher between client and server.
        /// </summary>
        internal ushort FrameRate => (_changeFrameRate) ? _frameRate : (ushort)0;
        #endregion

        #region Private.
        /// <summary>
        /// Last unscaled time client got a packet.
        /// </summary>
        private float _lastPacketTime;
        /// <summary>
        /// Updates information about the last packet received.
        /// </summary>
        private void UpdateLastPacketDatas()
        {
            _lastPacketTime = Time.unscaledTime;
            LastPacketLocalTick = NetworkManager.TimeManager.LocalTick;
        }
        /// <summary>
        /// Used to read splits.
        /// </summary>
        private SplitReader _splitReader = new SplitReader();
#if UNITY_EDITOR || DEVELOPMENT_BUILD
        /// <summary>
        /// Logs data about parser to help debug.
        /// </summary>
        private ParseLogger _parseLogger = new ParseLogger();
#endif
        #endregion

        private void OnDestroy()
        {
            Objects?.SubscribeToSceneLoaded(false);
        }


        /// <summary>
        /// Initializes this script for use.
        /// </summary>
        /// <param name="manager"></param>
        internal void InitializeOnce_Internal(NetworkManager manager)
        {
            NetworkManager = manager;
            Objects = new ClientObjects(manager);
            Objects.SubscribeToSceneLoaded(true);
            /* Unsubscribe before subscribing.
             * Shouldn't be an issue but better safe than sorry. */
            SubscribeToEvents(false);
            SubscribeToEvents(true);
            //Listen for client connections from server.
            RegisterBroadcast<ClientConnectionChangeBroadcast>(OnClientConnectionBroadcast);
            RegisterBroadcast<ConnectedClientsBroadcast>(OnConnectedClientsBroadcast);
        }


        /// <summary>
        /// Called when the server sends a connection state change for any client.
        /// </summary>
        /// <param name="args"></param>
        private void OnClientConnectionBroadcast(ClientConnectionChangeBroadcast args)
        {
            //If connecting invoke after added to clients, otherwise invoke before removed.
            RemoteConnectionStateArgs rcs = new RemoteConnectionStateArgs((args.Connected) ? RemoteConnectionState.Started : RemoteConnectionState.Stopped, args.Id, -1);

            if (args.Connected)
            {
                Clients[args.Id] = new NetworkConnection(NetworkManager, args.Id, -1, false);
                OnRemoteConnectionState?.Invoke(rcs);
            }
            else
            {
                OnRemoteConnectionState?.Invoke(rcs);
                if (Clients.TryGetValue(args.Id, out NetworkConnection c))
                {
                    c.Dispose();
                    Clients.Remove(args.Id);
                }
            }
        }

        /// <summary>
        /// Called when the server sends all currently connected clients.
        /// </summary>
        /// <param name="args"></param>
        private void OnConnectedClientsBroadcast(ConnectedClientsBroadcast args)
        {
            NetworkManager.ClearClientsCollection(Clients);

            List<int> collection = args.Values;
            //No connected clients except self.
            if (collection == null)
            {
                collection = new List<int>();
            }
            //Other clients.
            else
            {
                int count = collection.Count;
                for (int i = 0; i < count; i++)
                {
                    int id = collection[i];
                    Clients[id] = new NetworkConnection(NetworkManager, id, -1, false);
                }
            }

            OnConnectedClients?.Invoke(new ConnectedClientsArgs(collection));

        }

        /// <summary>
        /// Changes subscription status to transport.
        /// </summary>
        /// <param name="subscribe"></param>
        private void SubscribeToEvents(bool subscribe)
        {
            if (NetworkManager == null || NetworkManager.TransportManager == null || NetworkManager.TransportManager.Transport == null)
                return;

            if (subscribe)
            {
                NetworkManager.TransportManager.OnIterateIncomingEnd += TransportManager_OnIterateIncomingEnd;
                NetworkManager.TransportManager.Transport.OnClientReceivedData += Transport_OnClientReceivedData;
                NetworkManager.TransportManager.Transport.OnClientConnectionState += Transport_OnClientConnectionState;
                NetworkManager.TimeManager.OnPostTick += TimeManager_OnPostTick;
            }
            else
            {
                NetworkManager.TransportManager.OnIterateIncomingEnd -= TransportManager_OnIterateIncomingEnd;
                NetworkManager.TransportManager.Transport.OnClientReceivedData -= Transport_OnClientReceivedData;
                NetworkManager.TransportManager.Transport.OnClientConnectionState -= Transport_OnClientConnectionState;
                NetworkManager.TimeManager.OnPostTick -= TimeManager_OnPostTick;
            }
        }

        /// <summary>
        /// Gets the transport index being used for the local client.
        /// If only one transport is used this will return 0. If Multipass is being used this will return the client's transport in multipass.
        /// </summary>
        /// <returns></returns>
        public int GetTransportIndex()
        {
            if (NetworkManager.TransportManager.Transport is Multipass mp)
                return mp.ClientTransport.Index;
            else
                return 0;
        }

        /// <summary>
        /// Stops the local client connection.
        /// </summary>
        public bool StopConnection()
        {
            return NetworkManager.TransportManager.Transport.StopConnection(false);
        }

        /// <summary>
        /// Starts the local client connection.
        /// </summary>
        public bool StartConnection()
        {
            return NetworkManager.TransportManager.Transport.StartConnection(false);
        }

        /// <summary>
        /// Sets the transport address and starts the local client connection.
        /// </summary>
        public bool StartConnection(string address)
        {
            return StartConnection(address, NetworkManager.TransportManager.Transport.GetPort());
        }
        /// <summary>
        /// Sets the transport address and port, and starts the local client connection.
        /// </summary>
        public bool StartConnection(string address, ushort port)
        {
            NetworkManager.TransportManager.Transport.SetClientAddress(address);
            NetworkManager.TransportManager.Transport.SetPort(port);
            return StartConnection();
        }

        /// <summary>
        /// Called when a connection state changes for the local client.
        /// </summary>
        /// <param name="args"></param>
        private void Transport_OnClientConnectionState(ClientConnectionStateArgs args)
        {
            LocalConnectionState state = args.ConnectionState;
            Started = (state == LocalConnectionState.Started);
            Objects.OnClientConnectionState(args);

            //Clear connection after so objects can update using current Connection value.
            if (!Started)
            {
                Connection = NetworkManager.EmptyConnection;
                NetworkManager.ClearClientsCollection(Clients);
            }
            else
            {
                UpdateLastPacketDatas();
            }

            if (NetworkManager.CanLog(LoggingType.Common))
            {
                Transport t = NetworkManager.TransportManager.GetTransport(args.TransportIndex);
                string tName = (t == null) ? "Unknown" : t.GetType().Name;
                string socketInformation = string.Empty;
                if (state == LocalConnectionState.Starting)
                    socketInformation = $" Server IP is {t.GetClientAddress()}, port is {t.GetPort()}.";
                Debug.Log($"Local client is {state.ToString().ToLower()} for {tName}.{socketInformation}");
            }

            NetworkManager.UpdateFramerate();
            OnClientConnectionState?.Invoke(args);
        }

        /// <summary>
        /// Called when a socket receives data.
        /// </summary>
        private void Transport_OnClientReceivedData(ClientReceivedDataArgs args)
        {
            ParseReceived(args);
        }

        /// <summary>
        /// Called after IterateIncoming has completed.
        /// </summary>
        private void TransportManager_OnIterateIncomingEnd(bool server)
        {
            /* Should the last packet received be a spawn or despawn
             * then the cache won't yet be iterated because it only
             * iterates when a packet is anything but those two. Because
             * of such if any object caches did come in they must be iterated
             * at the end of the incoming cycle. This isn't as clean as I'd
             * like but it does ensure there will be no missing network object
             * references on spawned objects. */
            if (Started && !server)
                Objects.IterateObjectCache();
        }

        /// <summary>
        /// Parses received data.
        /// </summary>
        private void ParseReceived(ClientReceivedDataArgs args)
        {
#if UNITY_EDITOR || DEVELOPMENT_BUILD
            _parseLogger.Reset();
#endif
            UpdateLastPacketDatas();

            ArraySegment<byte> segment = args.Data;
            NetworkManager.StatisticsManager.NetworkTraffic.LocalClientReceivedData((ulong)segment.Count);
            if (segment.Count <= TransportManager.TICK_BYTES)
                return;

            PooledReader reader = ReaderPool.Retrieve(segment, NetworkManager, Reader.DataSource.Server);
            NetworkManager.TimeManager.SetLastPacketTick(reader.ReadTickUnpacked());
            ParseReader(reader, args.Channel);
            ReaderPool.Store(reader);

        }

        internal void ParseReader(PooledReader reader, Channel channel, bool print = false)
        {
            bool hasIntermediateLayer = NetworkManager.TransportManager.HasIntermediateLayer;
            PacketId packetId = PacketId.Unset;
#if !UNITY_EDITOR && !DEVELOPMENT_BUILD
            try
            {
#endif
            Reader.DataSource dataSource = Reader.DataSource.Server;
            /* This is a special condition where a message may arrive split.
            * When this occurs buffer each packet until all packets are
            * received. */
            if (reader.PeekPacketId() == PacketId.Split)
            {
                //Skip packetId.
                reader.ReadPacketId();
                int expectedMessages;
                _splitReader.GetHeader(reader, out expectedMessages);
                _splitReader.Write(NetworkManager.TimeManager.LastPacketTick, reader, expectedMessages);
                /* If fullMessage returns 0 count then the split
                 * has not written fully yet. Otherwise, if there is
                 * data within then reinitialize reader with the
                 * full message. */
                ArraySegment<byte> fullMessage = _splitReader.GetFullMessage();
                if (fullMessage.Count == 0)
                    return;

                //Initialize reader with full message.
                if (hasIntermediateLayer)
                    reader.Initialize(NetworkManager.TransportManager.ProcessIntermediateIncoming(fullMessage, true), NetworkManager, dataSource);
                else
                    reader.Initialize(fullMessage, NetworkManager, dataSource);
            }
            //Not split.
            else
            {
                //Override values with intermediate layer changes.
                if (hasIntermediateLayer)
                {
                    ArraySegment<byte> modified = NetworkManager.TransportManager.ProcessIntermediateIncoming(reader.GetRemainingData(), false);
                    reader.Initialize(modified, NetworkManager, dataSource);
                }
            }

            while (reader.Remaining > 0)
            {
                packetId = reader.ReadPacketId();
#if UNITY_EDITOR || DEVELOPMENT_BUILD
                if (print)
                    Debug.Log($"PacketId {packetId} - Remaining {reader.Remaining}.");
                _parseLogger.AddPacket(packetId);
#endif
                bool spawnOrDespawn = (packetId == PacketId.ObjectSpawn || packetId == PacketId.ObjectDespawn);
                /* Length of data. Only available if using unreliable. Unreliable packets
                 * can arrive out of order which means object orientated messages such as RPCs may
                 * arrive after the object for which they target has already been destroyed. When this happens
                 * on lesser solutions they just dump the entire packet. However, since FishNet batches data.
                 * it's very likely a packet will contain more than one packetId. With this mind, length is
                 * sent as well so if any reason the data does have to be dumped it will only be dumped for
                 * that single packetId  but not the rest. Broadcasts don't need length either even if unreliable
                 * because they are not object bound. */

                //Is spawn or despawn; cache packet.
                if (spawnOrDespawn)
                {
                    if (packetId == PacketId.ObjectSpawn)
                        Objects.CacheSpawn(reader);
                    else if (packetId == PacketId.ObjectDespawn)
                        Objects.CacheDespawn(reader);
                }
                //Not spawn or despawn.
                else
                {
                    /* Iterate object cache should any of the
                     * incoming packets rely on it. Objects
                     * in cache will always be received before any messages
                     * that use them. */
                    Objects.IterateObjectCache();
                    //Then process packet normally.
                    if ((ushort)packetId >= NetworkManager.StartingRpcLinkIndex)
                    {
                        Objects.ParseRpcLink(reader, (ushort)packetId, channel);
                    }
                    else if (packetId == PacketId.Replicate)
                    {
                        Objects.ParseReplicateRpc(reader, null, channel);
                    }
                    else if (packetId == PacketId.Reconcile)
                    {
                        Objects.ParseReconcileRpc(reader, channel);
                    }
                    else if (packetId == PacketId.ObserversRpc)
                    {
                        Objects.ParseObserversRpc(reader, channel);
                    }
                    else if (packetId == PacketId.TargetRpc)
                    {
                        Objects.ParseTargetRpc(reader, channel);
                    }
                    else if (packetId == PacketId.Broadcast)
                    {
                        ParseBroadcast(reader, channel);
                    }
                    else if (packetId == PacketId.PingPong)
                    {
                        ParsePingPong(reader);
                    }
                    else if (packetId == PacketId.SyncVar)
                    {
                        Objects.ParseSyncType(reader, false, channel);
                    }
                    else if (packetId == PacketId.SyncObject)
                    {
                        Objects.ParseSyncType(reader, true, channel);
                    }
                    else if (packetId == PacketId.PredictedSpawnResult)
                    {
                        Objects.ParsePredictedSpawnResult(reader);
                    }
                    else if (packetId == PacketId.TimingUpdate)
                    {
                        NetworkManager.TimeManager.ParseTimingUpdate(reader);
                    }
                    else if (packetId == PacketId.OwnershipChange)
                    {
                        Objects.ParseOwnershipChange(reader);
                    }
                    else if (packetId == PacketId.Authenticated)
                    {
                        ParseAuthenticated(reader);
                    }
                    else if (packetId == PacketId.Disconnect)
                    {
                        reader.Clear();
                        StopConnection();
                    }
                    else
                    {

                        NetworkManager.LogError($"Client received an unhandled PacketId of {(ushort)packetId} on channel {channel}. Remaining data has been purged.");
#if UNITY_EDITOR || DEVELOPMENT_BUILD
                        _parseLogger.Print(NetworkManager);
#endif
                        return;
                    }
                }

#if UNITY_EDITOR || DEVELOPMENT_BUILD
                if (print)
                    Debug.Log($"Reader remaining {reader.Remaining}");
#endif
            }

            /* Iterate cache when reader is emptied.
            * This is incase the last packet received
            * was a spawned, which wouldn't trigger
            * the above iteration. There's no harm
            * in doing this check multiple times as there's
            * an exit early check. */
            Objects.IterateObjectCache();
#if !UNITY_EDITOR && !DEVELOPMENT_BUILD
            }
            catch (Exception e)
            {
                if (NetworkManager.CanLog(LoggingType.Error))
                    Debug.LogError($"Client encountered an error while parsing data for packetId {packetId}. Message: {e.Message}.");
            }
#endif
        }

        /// <summary>
        /// Parses a PingPong packet.
        /// </summary>
        /// <param name="reader"></param>
        private void ParsePingPong(PooledReader reader)
        {
            uint clientTick = reader.ReadTickUnpacked();
            NetworkManager.TimeManager.ModifyPing(clientTick);
        }

        /// <summary>
        /// Parses a received connectionId. This is received before client receives connection state change.
        /// </summary>
        /// <param name="reader"></param>
        private void ParseAuthenticated(PooledReader reader)
        {
            NetworkManager networkManager = NetworkManager;
            int connectionId = reader.ReadNetworkConnectionId();
            //If only a client then make a new connection.
            if (!networkManager.IsServer)
            {
                Clients.TryGetValueIL2CPP(connectionId, out Connection);
                /* This is bad and should never happen unless the connection is dropping
                 * while receiving authenticated. Would have to be a crazy race condition
                 * but with the network anything is possible. */
                if (Connection == null)
                {
                    NetworkManager.LogWarning($"Client connection could not be found while parsing authenticated status. This usually occurs when the client is receiving a packet immediately before losing connection.");
                    Connection = new NetworkConnection(networkManager, connectionId, GetTransportIndex(), false);
                }
            }
            /* If also the server then use the servers connection
             * for the connectionId. This is to resolve host problems
             * where LocalConnection for client differs from the server Connection
             * reference, which results in different field values. */
            else
            {
                if (networkManager.ServerManager.Clients.TryGetValueIL2CPP(connectionId, out NetworkConnection conn))
                {
                    Connection = conn;
                }
                else
                {
                    networkManager.LogError($"Unable to lookup LocalConnection for {connectionId} as host.");
                    Connection = new NetworkConnection(networkManager, connectionId, GetTransportIndex(), false);
                }
            }

            //If predicted spawning is enabled also get reserved Ids.
            if (NetworkManager.PredictionManager.GetAllowPredictedSpawning())
            {
                byte count = reader.ReadByte();
                Queue<int> q = Connection.PredictedObjectIds;
                for (int i = 0; i < count; i++)
                    q.Enqueue(reader.ReadNetworkObjectId());
            }

            /* Set the TimeManager tick to lastReceivedTick.
             * This still doesn't account for latency but
             * it's the best we can do until the client gets
             * a ping response. */
            //Only do this if not also server.
            if (!networkManager.IsServer)
                networkManager.TimeManager.Tick = networkManager.TimeManager.LastPacketTick;

            //Mark as authenticated.
            Connection.ConnectionAuthenticated();
            OnAuthenticated?.Invoke();
            /* Register scene objects for all scenes
             * after being authenticated. This is done after
             * authentication rather than when the connection
             * is started because if also as server an online
             * scene may already be loaded on server, but not
             * for client. This means the sceneLoaded unity event
             * won't fire, and since client isn't authenticated
            * at the connection start phase objects won't be added. */
            Objects.RegisterAndDespawnSceneObjects();
        }

        /// <summary>
        /// Called when the TimeManager calls OnPostTick.
        /// </summary>
        private void TimeManager_OnPostTick()
        {
            CheckServerTimeout();
        }


        /// <summary>
        /// Checks to timeout client connections.
        /// </summary>
        private void CheckServerTimeout()
        {
            /* Not connected or host. There should be no way
             * for server to drop and client not know about it as host.
             * This would mean a game crash or force close in which
             * the client would be gone as well anyway. */
            if (!Started || NetworkManager.IsServer)
                return;
            if (_remoteServerTimeout == RemoteTimeoutType.Disabled)
                return;
#if UNITY_EDITOR || DEVELOPMENT_BUILD
            //If development but not set to development return.
            else if (_remoteServerTimeout != RemoteTimeoutType.Development)
                return;
#endif
            //Wait two timing intervals to give packets a chance to come through.
            if (NetworkManager.SceneManager.IsIteratingQueue(TimeManager.TIMING_INTERVAL * 2f))
                return;

            /* ServerManager version only checks every so often
             * to perform iterations over time so the checks are not
             * impactful on the CPU. The client however can check every tick
             * since it's simple math. */
            if (Time.unscaledTime - _lastPacketTime > _remoteServerTimeoutDuration)
            {
                OnClientTimeOut?.Invoke();
                NetworkManager.Log($"Server has timed out. You can modify this feature on the ClientManager component.");
                StopConnection();
            }
        }

    }

}
