using System;
using System.Collections.Generic;
using System.Linq;
using Mirror.RemoteCalls;
using UnityEngine;

namespace Mirror
{
    public enum ConnectState
    {
        None,
        // connecting between Connect() and OnTransportConnected()
        Connecting,
        Connected,
        // disconnecting between Disconnect() and OnTransportDisconnected()
        Disconnecting,
        Disconnected
    }

    /// <summary>NetworkClient with connection to server.</summary>
    public static partial class NetworkClient
    {
        // time & value snapshot interpolation are separate.
        // -> time is interpolated globally on NetworkClient / NetworkConnection
        // -> value is interpolated per-component, i.e. NetworkTransform.
        // however, both need to be on the same send interval.
        //
        // additionally, server & client need to use the same send interval.
        // otherwise it's too easy to accidentally cause interpolation issues if
        // a component sends with client.interval but interpolates with
        // server.interval, etc.
        public static int sendRate => NetworkServer.sendRate;
        public static float sendInterval => sendRate < int.MaxValue ? 1f / sendRate : 0; // for 30 Hz, that's 33ms
        static double lastSendTime;

        // For security, it is recommended to disconnect a player if a networked
        // action triggers an exception\nThis could prevent components being
        // accessed in an undefined state, which may be an attack vector for
        // exploits.
        //
        // However, some games may want to allow exceptions in order to not
        // interrupt the player's experience.
        public static bool exceptionsDisconnect = true; // security by default

        // message handlers by messageId
        internal static readonly Dictionary<ushort, NetworkMessageDelegate> handlers =
            new Dictionary<ushort, NetworkMessageDelegate>();

        /// <summary>All spawned NetworkIdentities by netId.</summary>
        // client sees OBSERVED spawned ones.
        public static readonly Dictionary<uint, NetworkIdentity> spawned =
            new Dictionary<uint, NetworkIdentity>();

        /// <summary>Client's NetworkConnection to server.</summary>
        public static NetworkConnection connection { get; internal set; }

        /// <summary>True if client is ready (= joined world).</summary>
        // TODO redundant state. point it to .connection.isReady instead (& test)
        // TODO OR remove NetworkConnection.isReady? unless it's used on server
        //
        // TODO maybe ClientState.Connected/Ready/AddedPlayer/etc.?
        //      way better for security if we can check states in callbacks
        public static bool ready;

        /// <summary>NetworkIdentity of the localPlayer </summary>
        public static NetworkIdentity localPlayer { get; internal set; }

        // NetworkClient state
        internal static ConnectState connectState = ConnectState.None;

        /// <summary>active is true while a client is connecting/connected either as standalone or as host client.</summary>
        // (= while the network is active)
        public static bool active => connectState == ConnectState.Connecting ||
                                     connectState == ConnectState.Connected;

        /// <summary>active is true while the client is connected in host mode.</summary>
        // naming consistent with NetworkServer.activeHost.
        public static bool activeHost => connection is LocalConnectionToServer;

        /// <summary>Check if client is connecting (before connected).</summary>
        public static bool isConnecting => connectState == ConnectState.Connecting;

        /// <summary>Check if client is connected (after connecting).</summary>
        public static bool isConnected => connectState == ConnectState.Connected;

        // OnConnected / OnDisconnected used to be NetworkMessages that were
        // invoked. this introduced a bug where external clients could send
        // Connected/Disconnected messages over the network causing undefined
        // behaviour.
        // => public so that custom NetworkManagers can hook into it
        public static Action OnConnectedEvent;
        public static Action OnDisconnectedEvent;
        public static Action<TransportError, string> OnErrorEvent;
        public static Action<Exception> OnTransportExceptionEvent;

        /// <summary>Registered spawnable prefabs by assetId.</summary>
        public static readonly Dictionary<uint, GameObject> prefabs =
            new Dictionary<uint, GameObject>();

        // custom spawn / unspawn handlers by assetId.
        // useful to support prefab pooling etc.:
        // https://mirror-networking.gitbook.io/docs/guides/gameobjects/custom-spawnfunctions
        internal static readonly Dictionary<uint, SpawnHandlerDelegate> spawnHandlers =
            new Dictionary<uint, SpawnHandlerDelegate>();
        internal static readonly Dictionary<uint, UnSpawnDelegate> unspawnHandlers =
            new Dictionary<uint, UnSpawnDelegate>();

        // spawning
        // internal for tests
        internal static bool isSpawnFinished;

        // Disabled scene objects that can be spawned again, by sceneId.
        internal static readonly Dictionary<ulong, NetworkIdentity> spawnableObjects =
            new Dictionary<ulong, NetworkIdentity>();

        internal static Unbatcher unbatcher = new Unbatcher();

        // interest management component (optional)
        // only needed for SetHostVisibility
        public static InterestManagementBase aoi;

        // scene loading
        public static bool isLoadingScene;

        // connection quality
        // this is set by a virtual function in NetworkManager,
        // which allows users to overwrite it with their own estimations.
        public static ConnectionQuality connectionQuality = ConnectionQuality.ESTIMATING;
        public static ConnectionQuality lastConnectionQuality = ConnectionQuality.ESTIMATING;
        public static ConnectionQualityMethod connectionQualityMethod = ConnectionQualityMethod.Simple;
        public static float connectionQualityInterval = 3;
        static double lastConnectionQualityUpdate;

        /// <summary>
        /// Invoked when connection quality changes.
        /// <para>First argument is the old quality, second argument is the new quality.</para>
        /// </summary>
        public static event Action<ConnectionQuality, ConnectionQuality> onConnectionQualityChanged;

        // initialization //////////////////////////////////////////////////////
        static void AddTransportHandlers()
        {
            // community Transports may forget to call OnDisconnected.
            // which could cause handlers to be added twice with +=.
            // ensure we always clear the old ones first.
            // fixes: https://github.com/vis2k/Mirror/issues/3152
            RemoveTransportHandlers();

            // += so that other systems can also hook into it (i.e. statistics)
            Transport.active.OnClientConnected += OnTransportConnected;
            Transport.active.OnClientDataReceived += OnTransportData;
            Transport.active.OnClientDisconnected += OnTransportDisconnected;
            Transport.active.OnClientError += OnTransportError;
            Transport.active.OnClientTransportException += OnTransportException;
        }

        static void RemoveTransportHandlers()
        {
            // -= so that other systems can also hook into it (i.e. statistics)
            Transport.active.OnClientConnected -= OnTransportConnected;
            Transport.active.OnClientDataReceived -= OnTransportData;
            Transport.active.OnClientDisconnected -= OnTransportDisconnected;
            Transport.active.OnClientError -= OnTransportError;
            Transport.active.OnClientTransportException -= OnTransportException;
        }

        // connect /////////////////////////////////////////////////////////////
        // initialize is called before every connect
        static void Initialize(bool hostMode)
        {
            // safety: ensure Weaving succeded.
            // if it silently failed, we would get lots of 'writer not found'
            // and other random errors at runtime instead. this is cleaner.
            if (!WeaverFuse.Weaved())
            {
                // if it failed, throw an exception to early exit all Connect calls.
                throw new Exception("NetworkClient won't start because Weaving failed or didn't run.");
            }

            // Debug.Log($"Client Connect: {address}");
            Debug.Assert(Transport.active != null, "There was no active transport when calling NetworkClient.Connect, If you are calling Connect manually then make sure to set 'Transport.active' first");

            // reset unbatcher in case any batches from last session remain.
            // need to do this in Initialize() so it runs for the host as well.
            // fixes host mode scene transition receiving data from previous scene.
            // credits: BigBoxVR
            unbatcher = new Unbatcher();

            // reset time interpolation on every new connect.
            // ensures last sessions' state is cleared before starting again.
            InitTimeInterpolation();

            RegisterMessageHandlers(hostMode);
            Transport.active.enabled = true;
        }

        /// <summary>Connect client to a NetworkServer by address.</summary>
        public static void Connect(string address)
        {
            Initialize(false);

            AddTransportHandlers();
            connectState = ConnectState.Connecting;
            Transport.active.ClientConnect(address);
            connection = new NetworkConnectionToServer();
        }

        /// <summary>Connect client to a NetworkServer by Uri.</summary>
        public static void Connect(Uri uri)
        {
            Initialize(false);

            AddTransportHandlers();
            connectState = ConnectState.Connecting;
            Transport.active.ClientConnect(uri);
            connection = new NetworkConnectionToServer();
        }

        // TODO why are there two connect host methods?
        // called from NetworkManager.FinishStartHost()
        public static void ConnectHost()
        {
            Initialize(true);
            connectState = ConnectState.Connected;
            HostMode.SetupConnections();
        }

        // disconnect //////////////////////////////////////////////////////////
        /// <summary>Disconnect from server.</summary>
        public static void Disconnect()
        {
            // only if connected or connecting.
            // don't disconnect() again if already in the process of
            // disconnecting or fully disconnected.
            if (connectState != ConnectState.Connecting &&
                connectState != ConnectState.Connected)
                return;

            // we are disconnecting until OnTransportDisconnected is called.
            // setting state to Disconnected would stop OnTransportDisconnected
            // from calling cleanup code because it would think we are already
            // disconnected fully.
            // TODO move to 'cleanup' code below if safe
            connectState = ConnectState.Disconnecting;
            ready = false;

            // call Disconnect on the NetworkConnection
            connection?.Disconnect();

            // IMPORTANT: do NOT clear connection here yet.
            // we still need it in OnTransportDisconnected for callbacks.
            // connection = null;
        }

        // transport events ////////////////////////////////////////////////////
        // called by Transport
        static void OnTransportConnected()
        {
            if (connection != null)
            {
                // reset network time stats
                NetworkTime.ResetStatics();

                // the handler may want to send messages to the client
                // thus we should set the connected state before calling the handler
                connectState = ConnectState.Connected;
                // ping right away after connecting so client gets new time asap
                NetworkTime.SendPing();
                OnConnectedEvent?.Invoke();
            }
            else Debug.LogError("Skipped Connect message handling because connection is null.");
        }

        // helper function
        static bool UnpackAndInvoke(NetworkReader reader, int channelId)
        {
            if (NetworkMessages.UnpackId(reader, out ushort msgType))
            {
                // try to invoke the handler for that message
                if (handlers.TryGetValue(msgType, out NetworkMessageDelegate handler))
                {
                    handler.Invoke(connection, reader, channelId);

                    // message handler may disconnect client, making connection = null
                    // therefore must check for null to avoid NRE.
                    if (connection != null)
                        connection.lastMessageTime = Time.time;

                    return true;
                }
                else
                {
                    // message in a batch are NOT length prefixed to save bandwidth.
                    // every message needs to be handled and read until the end.
                    // otherwise it would overlap into the next message.
                    // => need to warn and disconnect to avoid undefined behaviour.
                    // => WARNING, not error. can happen if attacker sends random data.
                    Debug.LogWarning($"Unknown message id: {msgType}. This can happen if no handler was registered for this message.");
                    // simply return false. caller is responsible for disconnecting.
                    //connection.Disconnect();
                    return false;
                }
            }
            else
            {
                // => WARNING, not error. can happen if attacker sends random data.
                Debug.LogWarning("Invalid message header.");
                // simply return false. caller is responsible for disconnecting.
                //connection.Disconnect();
                return false;
            }
        }

        // called by Transport
        internal static void OnTransportData(ArraySegment<byte> data, int channelId)
        {
            if (connection != null)
            {
                // server might batch multiple messages into one packet.
                // feed it to the Unbatcher.
                // NOTE: we don't need to associate a channelId because we
                //       always process all messages in the batch.
                if (!unbatcher.AddBatch(data))
                {
                    if (exceptionsDisconnect)
                    {
                        Debug.LogError($"NetworkClient: failed to add batch, disconnecting.");
                        connection.Disconnect();
                    }
                    else
                        Debug.LogWarning($"NetworkClient: failed to add batch.");

                    return;
                }

                // process all messages in the batch.
                // only while NOT loading a scene.
                // if we get a scene change message, then we need to stop
                // processing. otherwise we might apply them to the old scene.
                // => fixes https://github.com/vis2k/Mirror/issues/2651
                //
                // NOTE: is scene starts loading, then the rest of the batch
                //       would only be processed when OnTransportData is called
                //       the next time.
                //       => consider moving processing to NetworkEarlyUpdate.
                while (!isLoadingScene &&
                       unbatcher.GetNextMessage(out ArraySegment<byte> message, out double remoteTimestamp))
                {
                    using (NetworkReaderPooled reader = NetworkReaderPool.Get(message))
                    {
                        // enough to read at least header size?
                        if (reader.Remaining >= NetworkMessages.IdSize)
                        {
                            // make remoteTimeStamp available to the user
                            connection.remoteTimeStamp = remoteTimestamp;

                            // handle message
                            if (!UnpackAndInvoke(reader, channelId))
                            {
                                // warn, disconnect and return if failed
                                // -> warning because attackers might send random data
                                // -> messages in a batch aren't length prefixed.
                                //    failing to read one would cause undefined
                                //    behaviour for every message afterwards.
                                //    so we need to disconnect.
                                // -> return to avoid the below unbatches.count error.
                                //    we already disconnected and handled it.
                                if (exceptionsDisconnect)
                                {
                                    Debug.LogError($"NetworkClient: failed to unpack and invoke message. Disconnecting.");
                                    connection.Disconnect();
                                }
                                else
                                    Debug.LogWarning($"NetworkClient: failed to unpack and invoke message.");

                                return;
                            }
                        }
                        // otherwise disconnect
                        else
                        {
                            if (exceptionsDisconnect)
                            {
                                Debug.LogError($"NetworkClient: received Message was too short (messages should start with message id). Disconnecting.");
                                connection.Disconnect();
                            }
                            else
                                Debug.LogWarning("NetworkClient: received Message was too short (messages should start with message id)");
                            return;
                        }
                    }
                }

                // if we weren't interrupted by a scene change,
                // then all batched messages should have been processed now.
                // if not, we need to log an error to avoid debugging hell.
                // otherwise batches would silently grow.
                // we need to log an error to avoid debugging hell.
                //
                // EXAMPLE: https://github.com/vis2k/Mirror/issues/2882
                // -> UnpackAndInvoke silently returned because no handler for id
                // -> Reader would never be read past the end
                // -> Batch would never be retired because end is never reached
                //
                // NOTE: prefixing every message in a batch with a length would
                //       avoid ever not reading to the end. for extra bandwidth.
                //
                // IMPORTANT: always keep this check to detect memory leaks.
                //            this took half a day to debug last time.
                if (!isLoadingScene && unbatcher.BatchesCount > 0)
                {
                    Debug.LogError($"Still had {unbatcher.BatchesCount} batches remaining after processing, even though processing was not interrupted by a scene change. This should never happen, as it would cause ever growing batches.\nPossible reasons:\n* A message didn't deserialize as much as it serialized\n*There was no message handler for a message id, so the reader wasn't read until the end.");
                }
            }
            else Debug.LogError("Skipped Data message handling because connection is null.");
        }

        // called by Transport
        // IMPORTANT: often times when disconnecting, we call this from Mirror
        //            too because we want to remove the connection and handle
        //            the disconnect immediately.
        //            => which is fine as long as we guarantee it only runs once
        //            => which we do by setting the state to Disconnected!
        internal static void OnTransportDisconnected()
        {
            // StopClient called from user code triggers Disconnected event
            // from transport which calls StopClient again, so check here
            // and short circuit running the Shutdown process twice.
            if (connectState == ConnectState.Disconnected) return;

            // Raise the event before changing ConnectState
            // because 'active' depends on this during shutdown
            //
            // previously OnDisconnected was only invoked if connection != null.
            // however, if DNS resolve fails in Transport.Connect(),
            // OnDisconnected would never be called because 'connection' is only
            // created after the Transport.Connect() call.
            // fixes: https://github.com/MirrorNetworking/Mirror/issues/3365
            OnDisconnectedEvent?.Invoke();

            connectState = ConnectState.Disconnected;
            ready = false;
            snapshots.Clear();
            localTimeline = 0;

            // now that everything was handled, clear the connection.
            // previously this was done in Disconnect() already, but we still
            // need it for the above OnDisconnectedEvent.
            connection?.Cleanup();
            connection = null;

            // transport handlers are only added when connecting.
            // so only remove when actually disconnecting.
            RemoveTransportHandlers();
        }

        // transport errors are forwarded to high level
        static void OnTransportError(TransportError error, string reason)
        {
            // transport errors will happen. logging a warning is enough.
            // make sure the user does not panic.
            Debug.LogWarning($"Client Transport Error: {error}: {reason}. This is fine.");
            OnErrorEvent?.Invoke(error, reason);
        }

        static void OnTransportException(Exception exception)
        {
            // transport errors will happen. logging a warning is enough.
            // make sure the user does not panic.
            Debug.LogWarning($"Client Transport Exception: {exception}. This is fine.");
            OnTransportExceptionEvent?.Invoke(exception);
        }

        // send ////////////////////////////////////////////////////////////////
        /// <summary>Send a NetworkMessage to the server over the given channel.</summary>
        public static void Send<T>(T message, int channelId = Channels.Reliable)
            where T : struct, NetworkMessage
        {
            if (connection != null)
            {
                if (connectState == ConnectState.Connected)
                {
                    connection.Send(message, channelId);
                }
                else Debug.LogError("NetworkClient Send when not connected to a server");
            }
            else Debug.LogError("NetworkClient Send with no connection");
        }

        // message handlers ////////////////////////////////////////////////////
        internal static void RegisterMessageHandlers(bool hostMode)
        {
            // host mode client / remote client react to some messages differently.
            // but we still need to add handlers for all of them to avoid
            // 'message id not found' errors.
            if (hostMode)
            {
                RegisterHandler<ObjectDestroyMessage>(OnHostClientObjectDestroy);
                RegisterHandler<ObjectHideMessage>(OnHostClientObjectHide);
                RegisterHandler<NetworkPongMessage>(_ => { }, false);
                RegisterHandler<SpawnMessage>(OnHostClientSpawn);
                // host mode doesn't need spawning
                RegisterHandler<ObjectSpawnStartedMessage>(_ => { });
                // host mode doesn't need spawning
                RegisterHandler<ObjectSpawnFinishedMessage>(_ => { });
                // host mode doesn't need state updates
                RegisterHandler<EntityStateMessage>(_ => { });
            }
            else
            {
                RegisterHandler<ObjectDestroyMessage>(OnObjectDestroy);
                RegisterHandler<ObjectHideMessage>(OnObjectHide);
                RegisterHandler<NetworkPongMessage>(NetworkTime.OnClientPong, false);
                RegisterHandler<NetworkPingMessage>(NetworkTime.OnClientPing, false);
                RegisterHandler<SpawnMessage>(OnSpawn);
                RegisterHandler<ObjectSpawnStartedMessage>(OnObjectSpawnStarted);
                RegisterHandler<ObjectSpawnFinishedMessage>(OnObjectSpawnFinished);
                RegisterHandler<EntityStateMessage>(OnEntityStateMessage);
            }

            // These handlers are the same for host and remote clients
            RegisterHandler<TimeSnapshotMessage>(OnTimeSnapshotMessage);
            RegisterHandler<ChangeOwnerMessage>(OnChangeOwner);
            RegisterHandler<RpcMessage>(OnRPCMessage);
        }

        /// <summary>Register a handler for a message type T. Most should require authentication.</summary>
        public static void RegisterHandler<T>(Action<T> handler, bool requireAuthentication = true)
            where T : struct, NetworkMessage
        {
            ushort msgType = NetworkMessageId<T>.Id;
            if (handlers.ContainsKey(msgType))
            {
                Debug.LogWarning($"NetworkClient.RegisterHandler replacing handler for {typeof(T).FullName}, id={msgType}. If replacement is intentional, use ReplaceHandler instead to avoid this warning.");
            }

            // register Id <> Type in lookup for debugging.
            NetworkMessages.Lookup[msgType] = typeof(T);

            // we use the same WrapHandler function for server and client.
            // so let's wrap it to ignore the NetworkConnection parameter.
            // it's not needed on client. it's always NetworkClient.connection.
            void HandlerWrapped(NetworkConnection _, T value) => handler(value);
            handlers[msgType] = NetworkMessages.WrapHandler((Action<NetworkConnection, T>)HandlerWrapped, requireAuthentication, exceptionsDisconnect);
        }

        /// <summary>Register a handler for a message type T. Most should require authentication.</summary>
        // This version passes channelId to the handler.
        public static void RegisterHandler<T>(Action<T, int> handler, bool requireAuthentication = true)
            where T : struct, NetworkMessage
        {
            ushort msgType = NetworkMessageId<T>.Id;
            if (handlers.ContainsKey(msgType))
            {
                Debug.LogWarning($"NetworkClient.RegisterHandler replacing handler for {typeof(T).FullName}, id={msgType}. If replacement is intentional, use ReplaceHandler instead to avoid this warning.");
            }

            // register Id <> Type in lookup for debugging.
            NetworkMessages.Lookup[msgType] = typeof(T);

            // we use the same WrapHandler function for server and client.
            // so let's wrap it to ignore the NetworkConnection parameter.
            // it's not needed on client. it's always NetworkClient.connection.
            void HandlerWrapped(NetworkConnection _, T value, int channelId) => handler(value, channelId);
            handlers[msgType] = NetworkMessages.WrapHandler((Action<NetworkConnection, T, int>)HandlerWrapped, requireAuthentication, exceptionsDisconnect);
        }

        // Deprecated 2024-01-21
        [Obsolete("Use ReplaceHandler without the NetworkConnection parameter instead. This version is obsolete and will be removed soon.")]
        public static void ReplaceHandler<T>(Action<NetworkConnection, T> handler, bool requireAuthentication = true)
            where T : struct, NetworkMessage
        {
            // we use the same WrapHandler function for server and client.
            // so let's wrap it to ignore the NetworkConnection parameter.
            // it's not needed on client. it's always NetworkClient.connection.
            ushort msgType = NetworkMessageId<T>.Id;

            // register Id <> Type in lookup for debugging.
            NetworkMessages.Lookup[msgType] = typeof(T);

            void HandlerWrapped(NetworkConnection _, T value) => handler(_, value);
            handlers[msgType] = NetworkMessages.WrapHandler((Action<NetworkConnection, T>)HandlerWrapped, requireAuthentication, exceptionsDisconnect);
        }

        /// <summary>Replace a handler for a particular message type. Should require authentication by default.</summary>
        // RegisterHandler throws a warning (as it should) if a handler is assigned twice
        // Use of ReplaceHandler makes it clear the user intended to replace the handler
        public static void ReplaceHandler<T>(Action<T> handler, bool requireAuthentication = true)
            where T : struct, NetworkMessage
        {
            // we use the same WrapHandler function for server and client.
            // so let's wrap it to ignore the NetworkConnection parameter.
            // it's not needed on client. it's always NetworkClient.connection.
            ushort msgType = NetworkMessageId<T>.Id;

            // register Id <> Type in lookup for debugging.
            NetworkMessages.Lookup[msgType] = typeof(T);

            void HandlerWrapped(NetworkConnection _, T value) => handler(value);
            handlers[msgType] = NetworkMessages.WrapHandler((Action<NetworkConnection, T>)HandlerWrapped, requireAuthentication, exceptionsDisconnect);
        }

        /// <summary>Replace a handler for a particular message type. Should require authentication by default. This version passes channelId to the handler.</summary>
        // RegisterHandler throws a warning (as it should) if a handler is assigned twice
        // Use of ReplaceHandler makes it clear the user intended to replace the handler
        public static void ReplaceHandler<T>(Action<T, int> handler, bool requireAuthentication = true)
            where T : struct, NetworkMessage
        {
            // we use the same WrapHandler function for server and client.
            // so let's wrap it to ignore the NetworkConnection parameter.
            // it's not needed on client. it's always NetworkClient.connection.
            ushort msgType = NetworkMessageId<T>.Id;

            // register Id <> Type in lookup for debugging.
            NetworkMessages.Lookup[msgType] = typeof(T);

            void HandlerWrapped(NetworkConnection _, T value, int channelId) => handler(value, channelId);
            handlers[msgType] = NetworkMessages.WrapHandler((Action<NetworkConnection, T, int>)HandlerWrapped, requireAuthentication, exceptionsDisconnect);
        }

        /// <summary>Unregister a message handler of type T.</summary>
        public static bool UnregisterHandler<T>()
            where T : struct, NetworkMessage
        {
            // use int to minimize collisions
            ushort msgType = NetworkMessageId<T>.Id;
            return handlers.Remove(msgType);
        }

        // spawnable prefabs ///////////////////////////////////////////////////
        /// <summary>Find the registered prefab for this asset id.</summary>
        // Useful for debuggers
        public static bool GetPrefab(uint assetId, out GameObject prefab)
        {
            prefab = null;
            return assetId != 0 &&
                   prefabs.TryGetValue(assetId, out prefab) &&
                   prefab != null;
        }

        /// <summary>Validates Prefab then adds it to prefabs dictionary.</summary>
        static void RegisterPrefabIdentity(NetworkIdentity prefab)
        {
            if (prefab.assetId == 0)
            {
                Debug.LogError($"Can not Register '{prefab.name}' because it had empty assetid. If this is a scene Object use RegisterSpawnHandler instead");
                return;
            }

            if (prefab.sceneId != 0)
            {
                Debug.LogError($"Can not Register '{prefab.name}' because it has a sceneId, make sure you are passing in the original prefab and not an instance in the scene.");
                return;
            }

            // disallow child NetworkIdentities.
            // TODO likely not necessary anymore due to the new check in
            // NetworkIdentity.OnValidate.
            NetworkIdentity[] identities = prefab.GetComponentsInChildren<NetworkIdentity>();
            if (identities.Length > 1)
            {
                Debug.LogError($"Prefab '{prefab.name}' has multiple NetworkIdentity components. There should only be one NetworkIdentity on a prefab, and it must be on the root object.");
            }

            if (prefabs.ContainsKey(prefab.assetId))
            {
                GameObject existingPrefab = prefabs[prefab.assetId];
                Debug.LogWarning($"Replacing existing prefab with assetId '{prefab.assetId}'. Old prefab '{existingPrefab.name}', New prefab '{prefab.name}'");
            }

            if (spawnHandlers.ContainsKey(prefab.assetId) || unspawnHandlers.ContainsKey(prefab.assetId))
            {
                Debug.LogWarning($"Adding prefab '{prefab.name}' with assetId '{prefab.assetId}' when spawnHandlers with same assetId already exists. If you want to use custom spawn handling, then remove the prefab from NetworkManager's registered prefabs first.");
            }

            // Debug.Log($"Registering prefab '{prefab.name}' as asset:{prefab.assetId}");

            prefabs[prefab.assetId] = prefab.gameObject;
        }

        /// <summary>Register spawnable prefab with custom assetId.</summary>
        // Note: newAssetId can not be set on GameObjects that already have an assetId
        // Note: registering with assetId is useful for assetbundles etc. a lot
        //       of people use this.
        public static void RegisterPrefab(GameObject prefab, uint newAssetId)
        {
            if (prefab == null)
            {
                Debug.LogError("Could not register prefab because it was null");
                return;
            }

            if (newAssetId == 0)
            {
                Debug.LogError($"Could not register '{prefab.name}' with new assetId because the new assetId was empty");
                return;
            }

            if (!prefab.TryGetComponent(out NetworkIdentity identity))
            {
                Debug.LogError($"Could not register '{prefab.name}' since it contains no NetworkIdentity component");
                return;
            }

            if (identity.assetId != 0 && identity.assetId != newAssetId)
            {
                Debug.LogError($"Could not register '{prefab.name}' to {newAssetId} because it already had an AssetId, Existing assetId {identity.assetId}");
                return;
            }

            identity.assetId = newAssetId;

            RegisterPrefabIdentity(identity);
        }

        /// <summary>Register spawnable prefab.</summary>
        public static void RegisterPrefab(GameObject prefab)
        {
            if (prefab == null)
            {
                Debug.LogError("Could not register prefab because it was null");
                return;
            }

            if (!prefab.TryGetComponent(out NetworkIdentity identity))
            {
                Debug.LogError($"Could not register '{prefab.name}' since it contains no NetworkIdentity component");
                return;
            }

            RegisterPrefabIdentity(identity);
        }

        /// <summary>Register a spawnable prefab with custom assetId and custom spawn/unspawn handlers.</summary>
        // Note: newAssetId can not be set on GameObjects that already have an assetId
        // Note: registering with assetId is useful for assetbundles etc. a lot
        //       of people use this.
        // TODO why do we have one with SpawnDelegate and one with SpawnHandlerDelegate?
        public static void RegisterPrefab(GameObject prefab, uint newAssetId, SpawnDelegate spawnHandler, UnSpawnDelegate unspawnHandler)
        {
            // We need this check here because we don't want a null handler in the lambda expression below
            if (spawnHandler == null)
            {
                Debug.LogError($"Can not Register null SpawnHandler for {newAssetId}");
                return;
            }

            RegisterPrefab(prefab, newAssetId, msg => spawnHandler(msg.position, msg.assetId), unspawnHandler);
        }

        /// <summary>Register a spawnable prefab with custom spawn/unspawn handlers.</summary>
        // TODO why do we have one with SpawnDelegate and one with SpawnHandlerDelegate?
        public static void RegisterPrefab(GameObject prefab, SpawnDelegate spawnHandler, UnSpawnDelegate unspawnHandler)
        {
            if (prefab == null)
            {
                Debug.LogError("Could not register handler for prefab because the prefab was null");
                return;
            }

            if (!prefab.TryGetComponent(out NetworkIdentity identity))
            {
                Debug.LogError($"Could not register handler for '{prefab.name}' since it contains no NetworkIdentity component");
                return;
            }

            if (identity.sceneId != 0)
            {
                Debug.LogError($"Can not Register '{prefab.name}' because it has a sceneId, make sure you are passing in the original prefab and not an instance in the scene.");
                return;
            }

            if (identity.assetId == 0)
            {
                Debug.LogError($"Can not Register handler for '{prefab.name}' because it had empty assetid. If this is a scene Object use RegisterSpawnHandler instead");
                return;
            }

            // We need this check here because we don't want a null handler in the lambda expression below
            if (spawnHandler == null)
            {
                Debug.LogError($"Can not Register null SpawnHandler for {identity.assetId}");
                return;
            }

            RegisterPrefab(prefab, msg => spawnHandler(msg.position, msg.assetId), unspawnHandler);
        }

        /// <summary>Register a spawnable prefab with custom assetId and custom spawn/unspawn handlers.</summary>
        // Note: newAssetId can not be set on GameObjects that already have an assetId
        // Note: registering with assetId is useful for assetbundles etc. a lot
        //       of people use this.
        // TODO why do we have one with SpawnDelegate and one with SpawnHandlerDelegate?
        public static void RegisterPrefab(GameObject prefab, uint newAssetId, SpawnHandlerDelegate spawnHandler, UnSpawnDelegate unspawnHandler)
        {
            if (newAssetId == 0)
            {
                Debug.LogError($"Could not register handler for '{prefab.name}' with new assetId because the new assetId was empty");
                return;
            }

            if (prefab == null)
            {
                Debug.LogError("Could not register handler for prefab because the prefab was null");
                return;
            }

            if (!prefab.TryGetComponent(out NetworkIdentity identity))
            {
                Debug.LogError($"Could not register handler for '{prefab.name}' since it contains no NetworkIdentity component");
                return;
            }

            if (identity.assetId != 0 && identity.assetId != newAssetId)
            {
                Debug.LogError($"Could not register Handler for '{prefab.name}' to {newAssetId} because it already had an AssetId, Existing assetId {identity.assetId}");
                return;
            }

            if (identity.sceneId != 0)
            {
                Debug.LogError($"Can not Register '{prefab.name}' because it has a sceneId, make sure you are passing in the original prefab and not an instance in the scene.");
                return;
            }

            identity.assetId = newAssetId;
            uint assetId = identity.assetId;

            if (spawnHandler == null)
            {
                Debug.LogError($"Can not Register null SpawnHandler for {assetId}");
                return;
            }

            if (unspawnHandler == null)
            {
                Debug.LogError($"Can not Register null UnSpawnHandler for {assetId}");
                return;
            }

            if (spawnHandlers.ContainsKey(assetId) || unspawnHandlers.ContainsKey(assetId))
            {
                Debug.LogWarning($"Replacing existing spawnHandlers for prefab '{prefab.name}' with assetId '{assetId}'");
            }

            if (prefabs.ContainsKey(assetId))
            {
                // this is error because SpawnPrefab checks prefabs before handler
                Debug.LogError($"assetId '{assetId}' is already used by prefab '{prefabs[assetId].name}', unregister the prefab first before trying to add handler");
            }

            NetworkIdentity[] identities = prefab.GetComponentsInChildren<NetworkIdentity>();
            if (identities.Length > 1)
            {
                Debug.LogError($"Prefab '{prefab.name}' has multiple NetworkIdentity components. There should only be one NetworkIdentity on a prefab, and it must be on the root object.");
            }

            //Debug.Log($"Registering custom prefab {prefab.name} as asset:{assetId} {spawnHandler.GetMethodName()}/{unspawnHandler.GetMethodName()}");

            spawnHandlers[assetId] = spawnHandler;
            unspawnHandlers[assetId] = unspawnHandler;
        }

        /// <summary>Register a spawnable prefab with custom spawn/unspawn handlers.</summary>
        // TODO why do we have one with SpawnDelegate and one with SpawnHandlerDelegate?
        public static void RegisterPrefab(GameObject prefab, SpawnHandlerDelegate spawnHandler, UnSpawnDelegate unspawnHandler)
        {
            if (prefab == null)
            {
                Debug.LogError("Could not register handler for prefab because the prefab was null");
                return;
            }

            if (!prefab.TryGetComponent(out NetworkIdentity identity))
            {
                Debug.LogError($"Could not register handler for '{prefab.name}' since it contains no NetworkIdentity component");
                return;
            }

            if (identity.sceneId != 0)
            {
                Debug.LogError($"Can not Register '{prefab.name}' because it has a sceneId, make sure you are passing in the original prefab and not an instance in the scene.");
                return;
            }

            uint assetId = identity.assetId;

            if (assetId == 0)
            {
                Debug.LogError($"Can not Register handler for '{prefab.name}' because it had empty assetid. If this is a scene Object use RegisterSpawnHandler instead");
                return;
            }

            if (spawnHandler == null)
            {
                Debug.LogError($"Can not Register null SpawnHandler for {assetId}");
                return;
            }

            if (unspawnHandler == null)
            {
                Debug.LogError($"Can not Register null UnSpawnHandler for {assetId}");
                return;
            }

            if (spawnHandlers.ContainsKey(assetId) || unspawnHandlers.ContainsKey(assetId))
            {
                Debug.LogWarning($"Replacing existing spawnHandlers for prefab '{prefab.name}' with assetId '{assetId}'");
            }

            if (prefabs.ContainsKey(assetId))
            {
                // this is error because SpawnPrefab checks prefabs before handler
                Debug.LogError($"assetId '{assetId}' is already used by prefab '{prefabs[assetId].name}', unregister the prefab first before trying to add handler");
            }

            NetworkIdentity[] identities = prefab.GetComponentsInChildren<NetworkIdentity>();
            if (identities.Length > 1)
            {
                Debug.LogError($"Prefab '{prefab.name}' has multiple NetworkIdentity components. There should only be one NetworkIdentity on a prefab, and it must be on the root object.");
            }

            //Debug.Log($"Registering custom prefab {prefab.name} as asset:{assetId} {spawnHandler.GetMethodName()}/{unspawnHandler.GetMethodName()}");

            spawnHandlers[assetId] = spawnHandler;
            unspawnHandlers[assetId] = unspawnHandler;
        }

        /// <summary>Removes a registered spawn prefab that was setup with NetworkClient.RegisterPrefab.</summary>
        public static void UnregisterPrefab(GameObject prefab)
        {
            if (prefab == null)
            {
                Debug.LogError("Could not unregister prefab because it was null");
                return;
            }

            if (!prefab.TryGetComponent(out NetworkIdentity identity))
            {
                Debug.LogError($"Could not unregister '{prefab.name}' since it contains no NetworkIdentity component");
                return;
            }

            uint assetId = identity.assetId;

            prefabs.Remove(assetId);
            spawnHandlers.Remove(assetId);
            unspawnHandlers.Remove(assetId);
        }

        // spawn handlers //////////////////////////////////////////////////////
        /// <summary>This is an advanced spawning function that registers a custom assetId with the spawning system.</summary>
        // This can be used to register custom spawning methods for an assetId -
        // instead of the usual method of registering spawning methods for a
        // prefab. This should be used when no prefab exists for the spawned
        // objects - such as when they are constructed dynamically at runtime
        // from configuration data.
        public static void RegisterSpawnHandler(uint assetId, SpawnDelegate spawnHandler, UnSpawnDelegate unspawnHandler)
        {
            // We need this check here because we don't want a null handler in the lambda expression below
            if (spawnHandler == null)
            {
                Debug.LogError($"Can not Register null SpawnHandler for {assetId}");
                return;
            }

            RegisterSpawnHandler(assetId, msg => spawnHandler(msg.position, msg.assetId), unspawnHandler);
        }

        /// <summary>This is an advanced spawning function that registers a custom assetId with the spawning system.</summary>
        // This can be used to register custom spawning methods for an assetId -
        // instead of the usual method of registering spawning methods for a
        // prefab. This should be used when no prefab exists for the spawned
        // objects - such as when they are constructed dynamically at runtime
        // from configuration data.
        public static void RegisterSpawnHandler(uint assetId, SpawnHandlerDelegate spawnHandler, UnSpawnDelegate unspawnHandler)
        {
            if (spawnHandler == null)
            {
                Debug.LogError($"Can not Register null SpawnHandler for {assetId}");
                return;
            }

            if (unspawnHandler == null)
            {
                Debug.LogError($"Can not Register null UnSpawnHandler for {assetId}");
                return;
            }

            if (assetId == 0)
            {
                Debug.LogError("Can not Register SpawnHandler for empty assetId");
                return;
            }

            if (spawnHandlers.ContainsKey(assetId) || unspawnHandlers.ContainsKey(assetId))
            {
                Debug.LogWarning($"Replacing existing spawnHandlers for {assetId}");
            }

            if (prefabs.ContainsKey(assetId))
            {
                // this is error because SpawnPrefab checks prefabs before handler
                Debug.LogError($"assetId '{assetId}' is already used by prefab '{prefabs[assetId].name}'");
            }

            // Debug.Log("RegisterSpawnHandler asset {assetId} {spawnHandler.GetMethodName()}/{unspawnHandler.GetMethodName()}");

            spawnHandlers[assetId] = spawnHandler;
            unspawnHandlers[assetId] = unspawnHandler;
        }

        /// <summary> Removes a registered spawn handler function that was registered with NetworkClient.RegisterHandler().</summary>
        public static void UnregisterSpawnHandler(uint assetId)
        {
            spawnHandlers.Remove(assetId);
            unspawnHandlers.Remove(assetId);
        }

        /// <summary>This clears the registered spawn prefabs and spawn handler functions for this client.</summary>
        public static void ClearSpawners()
        {
            prefabs.Clear();
            spawnHandlers.Clear();
            unspawnHandlers.Clear();
        }

        internal static bool InvokeUnSpawnHandler(uint assetId, GameObject obj)
        {
            if (unspawnHandlers.TryGetValue(assetId, out UnSpawnDelegate handler) && handler != null)
            {
                handler(obj);
                return true;
            }
            return false;
        }

        // ready ///////////////////////////////////////////////////////////////
        /// <summary>Sends Ready message to server, indicating that we loaded the scene, ready to enter the game.</summary>
        // This could be for example when a client enters an ongoing game and
        // has finished loading the current scene. The server should respond to
        // the SYSTEM_READY event with an appropriate handler which instantiates
        // the players object for example.
        public static bool Ready()
        {
            // Debug.Log($"NetworkClient.Ready() called with connection {conn}");
            if (ready)
            {
                Debug.LogError("NetworkClient is already ready. It shouldn't be called twice.");
                return false;
            }

            // need a valid connection to become ready
            if (connection == null)
            {
                Debug.LogError("Ready() called with invalid connection object: conn=null");
                return false;
            }

            // Set these before sending the ReadyMessage, otherwise host client
            // will fail in InternalAddPlayer with null readyConnection.
            // TODO this is redundant. have one source of truth for .ready
            ready = true;
            connection.isReady = true;

            // Tell server we're ready to have a player object spawned
            connection.Send(new ReadyMessage());
            return true;
        }

        // add player //////////////////////////////////////////////////////////
        // called from message handler for Owner message
        internal static void InternalAddPlayer(NetworkIdentity identity)
        {
            //Debug.Log("NetworkClient.InternalAddPlayer");

            // NOTE: It can be "normal" when changing scenes for the player to be destroyed and recreated.
            // But, the player structures are not cleaned up, we'll just replace the old player
            localPlayer = identity;

            // NOTE: we DONT need to set isClient=true here, because OnStartClient
            // is called before OnStartLocalPlayer, hence it's already set.
            // localPlayer.isClient = true;

            // TODO this check might not be necessary
            //if (readyConnection != null)
            if (ready && connection != null)
            {
                connection.identity = identity;
            }
            else Debug.LogWarning("NetworkClient can't AddPlayer before being ready. Please call NetworkClient.Ready() first. Clients are considered ready after joining the game world.");
        }

        /// <summary>Sends AddPlayer message to the server, indicating that we want to join the world.</summary>
        public static bool AddPlayer()
        {
            // ensure valid ready connection
            if (connection == null)
            {
                Debug.LogError("AddPlayer requires a valid NetworkClient.connection.");
                return false;
            }

            // UNET checked 'if readyConnection != null'.
            // in other words, we need a connection and we need to be ready.
            if (!ready)
            {
                Debug.LogError("AddPlayer requires a ready NetworkClient.");
                return false;
            }

            if (connection.identity != null)
            {
                Debug.LogError("NetworkClient.AddPlayer: a PlayerController was already added. Did you call AddPlayer twice?");
                return false;
            }

            // Debug.Log($"NetworkClient.AddPlayer() called with connection {readyConnection}");
            connection.Send(new AddPlayerMessage());
            return true;
        }

        // spawning ////////////////////////////////////////////////////////////
        internal static void ApplySpawnPayload(NetworkIdentity identity, SpawnMessage message)
        {
            if (message.assetId != 0)
                identity.assetId = message.assetId;

            if (!identity.gameObject.activeSelf)
            {
                identity.gameObject.SetActive(true);
            }

            // apply local values for VR support
            identity.transform.localPosition = message.position;
            identity.transform.localRotation = message.rotation;
            identity.transform.localScale = message.scale;

            // configure flags
            // the below DeserializeClient call invokes SyncVarHooks.
            // flags always need to be initialized before that.
            // fixes: https://github.com/MirrorNetworking/Mirror/issues/3259
            identity.isOwned = message.isOwner;
            identity.netId = message.netId;

            if (message.isLocalPlayer)
                InternalAddPlayer(identity);

            // configure isClient/isLocalPlayer flags.
            // => after InternalAddPlayer. can't initialize .isLocalPlayer
            //    before InternalAddPlayer sets .localPlayer
            // => before DeserializeClient, otherwise SyncVar hooks wouldn't
            //    have isClient/isLocalPlayer set yet.
            //    fixes: https://github.com/MirrorNetworking/Mirror/issues/3259
            InitializeIdentityFlags(identity);

            // deserialize components if any payload
            // (Count is 0 if there were no components)
            if (message.payload.Count > 0)
            {
                using (NetworkReaderPooled payloadReader = NetworkReaderPool.Get(message.payload))
                {
                    identity.DeserializeClient(payloadReader, true);
                }
            }

            spawned[message.netId] = identity;
            if (identity.isOwned) connection?.owned.Add(identity);

            // the initial spawn with OnObjectSpawnStarted/Finished calls all
            // object's OnStartClient/OnStartLocalPlayer after they were all
            // spawned.
            // this only happens once though.
            // for all future spawns, we need to call OnStartClient/LocalPlayer
            // here immediately since there won't be another OnObjectSpawnFinished.
            if (isSpawnFinished)
            {
                InvokeIdentityCallbacks(identity);
            }
        }

        // Finds Existing Object with NetId or spawns a new one using AssetId or sceneId
        internal static bool FindOrSpawnObject(SpawnMessage message, out NetworkIdentity identity)
        {
            // was the object already spawned?
            identity = GetExistingObject(message.netId);

            // if found, return early
            if (identity != null)
            {
                return true;
            }

            if (message.assetId == 0 && message.sceneId == 0)
            {
                Debug.LogError($"OnSpawn message with netId '{message.netId}' has no AssetId or sceneId");
                return false;
            }

            identity = message.sceneId == 0 ? SpawnPrefab(message) : SpawnSceneObject(message.sceneId);

            if (identity == null)
            {
                Debug.LogError($"Could not spawn assetId={message.assetId} scene={message.sceneId:X} netId={message.netId}");
                return false;
            }

            return true;
        }

        static NetworkIdentity GetExistingObject(uint netid)
        {
            spawned.TryGetValue(netid, out NetworkIdentity identity);
            return identity;
        }

        static NetworkIdentity SpawnPrefab(SpawnMessage message)
        {
            // custom spawn handler for this prefab? (for prefab pools etc.)
            //
            // IMPORTANT: look for spawn handlers BEFORE looking for registered
            //            prefabs. Unspawning also looks for unspawn handlers
            //            before falling back to regular Destroy. this needs to
            //            be consistent.
            //            https://github.com/vis2k/Mirror/issues/2705
            if (spawnHandlers.TryGetValue(message.assetId, out SpawnHandlerDelegate handler))
            {
                GameObject obj = handler(message);
                if (obj == null)
                {
                    Debug.LogError($"Spawn Handler returned null, Handler assetId '{message.assetId}'");
                    return null;
                }

                if (!obj.TryGetComponent(out NetworkIdentity identity))
                {
                    Debug.LogError($"Object Spawned by handler did not have a NetworkIdentity, Handler assetId '{message.assetId}'");
                    return null;
                }

                return identity;
            }

            // otherwise look in NetworkManager registered prefabs
            if (GetPrefab(message.assetId, out GameObject prefab))
            {
                GameObject obj = GameObject.Instantiate(prefab, message.position, message.rotation);
                //Debug.Log($"Client spawn handler instantiating [netId{message.netId} asset ID:{message.assetId} pos:{message.position} rotation:{message.rotation}]");
                return obj.GetComponent<NetworkIdentity>();
            }

            Debug.LogError($"Failed to spawn server object, did you forget to add it to the NetworkManager? assetId={message.assetId} netId={message.netId}");
            return null;
        }

        static NetworkIdentity SpawnSceneObject(ulong sceneId)
        {
            NetworkIdentity identity = GetAndRemoveSceneObject(sceneId);
            if (identity == null)
            {
                Debug.LogError($"Spawn scene object not found for {sceneId:X}. Make sure that client and server use exactly the same project. This only happens if the hierarchy gets out of sync.");

                // dump the whole spawnable objects dict for easier debugging
                //foreach (KeyValuePair<ulong, NetworkIdentity> kvp in spawnableObjects)
                //    Debug.Log($"Spawnable: SceneId={kvp.Key:X} name={kvp.Value.name}");
            }
            //else Debug.Log($"Client spawn for [netId:{msg.netId}] [sceneId:{msg.sceneId:X}] obj:{identity}");
            return identity;
        }

        static NetworkIdentity GetAndRemoveSceneObject(ulong sceneId)
        {
            if (spawnableObjects.TryGetValue(sceneId, out NetworkIdentity identity))
            {
                spawnableObjects.Remove(sceneId);
                return identity;
            }
            return null;
        }

        /// <summary>Call this after loading/unloading a scene in the client after connection to register the spawnable objects</summary>
        public static void PrepareToSpawnSceneObjects()
        {
            // remove existing items, they will be re-added below
            spawnableObjects.Clear();

            // finds all NetworkIdentity currently loaded by unity (includes disabled objects)
            NetworkIdentity[] allIdentities = Resources.FindObjectsOfTypeAll<NetworkIdentity>();
            foreach (NetworkIdentity identity in allIdentities)
            {
                // add all unspawned NetworkIdentities to spawnable objects
                // need to check netId to make sure object is not spawned
                // fixes: https://github.com/MirrorNetworking/Mirror/issues/3541
                // PrepareToSpawnSceneObjects may be called multiple times in case
                // the ObjectSpawnStarted message is received multiple times.
                if (Utils.IsSceneObject(identity) &&
                    identity.netId == 0)
                {
                    if (spawnableObjects.TryGetValue(identity.sceneId, out NetworkIdentity existingIdentity))
                    {
                        string msg = $"NetworkClient: Duplicate sceneId {identity.sceneId} detected on {identity.gameObject.name} and {existingIdentity.gameObject.name}\n" +
                            $"This can happen if a networked object is persisted in DontDestroyOnLoad through loading / changing to the scene where it originated,\n" +
                            $"otherwise you may need to open and re-save the {identity.gameObject.scene} to reset scene id's.";
                        Debug.LogWarning(msg, identity.gameObject);
                    }
                    else
                    {
                        spawnableObjects.Add(identity.sceneId, identity);
                    }
                }
            }
        }

        internal static void OnObjectSpawnStarted(ObjectSpawnStartedMessage _)
        {
            // Debug.Log("SpawnStarted");
            PrepareToSpawnSceneObjects();
            isSpawnFinished = false;
        }

        internal static void OnObjectSpawnFinished(ObjectSpawnFinishedMessage _)
        {
            // paul: Initialize the objects in the same order as they were
            // initialized in the server. This is important if spawned objects
            // use data from scene objects
            foreach (NetworkIdentity identity in spawned.Values.OrderBy(uv => uv.netId))
            {
                // NetworkIdentities should always be removed from .spawned when
                // they are destroyed. for safety, let's double check here.
                if (identity != null)
                {
                    BootstrapIdentity(identity);
                }
                else Debug.LogWarning("Found null entry in NetworkClient.spawned. This is unexpected. Was the NetworkIdentity not destroyed properly?");
            }
            isSpawnFinished = true;
        }

        // host mode callbacks /////////////////////////////////////////////////
        static void OnHostClientObjectDestroy(ObjectDestroyMessage message)
        {
            //Debug.Log($"NetworkClient.OnLocalObjectObjDestroy netId:{message.netId}");

            // remove from owned (if any)
            if (spawned.TryGetValue(message.netId, out NetworkIdentity identity))
                connection.owned.Remove(identity);

            spawned.Remove(message.netId);
        }

        static void OnHostClientObjectHide(ObjectHideMessage message)
        {
            //Debug.Log($"ClientScene::OnLocalObjectObjHide netId:{message.netId}");
            if (spawned.TryGetValue(message.netId, out NetworkIdentity identity) &&
                identity != null)
            {
                if (aoi != null)
                    aoi.SetHostVisibility(identity, false);
            }
        }

        internal static void OnHostClientSpawn(SpawnMessage message)
        {
            // on host mode, the object already exist in NetworkServer.spawned.
            // simply add it to NetworkClient.spawned too.
            if (NetworkServer.spawned.TryGetValue(message.netId, out NetworkIdentity identity) && identity != null)
            {
                spawned[message.netId] = identity;
                if (message.isOwner) connection.owned.Add(identity);

                // now do the actual 'spawning' on host mode
                if (message.isLocalPlayer)
                    InternalAddPlayer(identity);

                // set visibility before invoking OnStartClient etc. callbacks
                if (aoi != null)
                    aoi.SetHostVisibility(identity, true);

                identity.isOwned = message.isOwner;
                BootstrapIdentity(identity);
            }
        }

        // client-only mode callbacks //////////////////////////////////////////
        static void OnEntityStateMessage(EntityStateMessage message)
        {
            // Debug.Log($"NetworkClient.OnUpdateVarsMessage {msg.netId}");
            if (spawned.TryGetValue(message.netId, out NetworkIdentity identity) && identity != null)
            {
                using (NetworkReaderPooled reader = NetworkReaderPool.Get(message.payload))
                    identity.DeserializeClient(reader, false);
            }
            else Debug.LogWarning($"Did not find target for sync message for {message.netId}. Were all prefabs added to the NetworkManager's spawnable list?\nNote: this can be completely normal because UDP messages may arrive out of order, so this message might have arrived after a Destroy message.");
        }

        static void OnRPCMessage(RpcMessage message)
        {
            // Debug.Log($"NetworkClient.OnRPCMessage hash:{message.functionHash} netId:{message.netId}");
            if (spawned.TryGetValue(message.netId, out NetworkIdentity identity))
            {
                using (NetworkReaderPooled reader = NetworkReaderPool.Get(message.payload))
                    identity.HandleRemoteCall(message.componentIndex, message.functionHash, RemoteCallType.ClientRpc, reader);
            }
            // Rpcs often can't be applied if interest management unspawned them
        }

        static void OnObjectHide(ObjectHideMessage message) => DestroyObject(message.netId);

        internal static void OnObjectDestroy(ObjectDestroyMessage message) => DestroyObject(message.netId);

        internal static void OnSpawn(SpawnMessage message)
        {
            // Debug.Log($"Client spawn handler instantiating netId={msg.netId} assetID={msg.assetId} sceneId={msg.sceneId:X} pos={msg.position}");
            if (FindOrSpawnObject(message, out NetworkIdentity identity))
            {
                ApplySpawnPayload(identity, message);
            }
        }

        internal static void OnChangeOwner(ChangeOwnerMessage message)
        {
            NetworkIdentity identity = GetExistingObject(message.netId);

            if (identity != null)
                ChangeOwner(identity, message);
            else
                Debug.LogError($"OnChangeOwner: Could not find object with netId {message.netId}");
        }

        // ChangeOwnerMessage contains new 'owned' and new 'localPlayer'
        // that we need to apply to the identity.
        internal static void ChangeOwner(NetworkIdentity identity, ChangeOwnerMessage message)
        {
            // local player before, but not anymore?
            // call OnStopLocalPlayer before setting new values.
            if (identity.isLocalPlayer && !message.isLocalPlayer)
            {
                identity.OnStopLocalPlayer();
            }

            // set ownership flag (aka authority)
            identity.isOwned = message.isOwner;

            // Add / Remove to client's connectionToServer.owned hashset.
            if (identity.isOwned)
                connection?.owned.Add(identity);
            else
                connection?.owned.Remove(identity);

            // Call OnStartAuthority / OnStopAuthority
            identity.NotifyAuthority();

            // set localPlayer flag
            identity.isLocalPlayer = message.isLocalPlayer;

            // identity is now local player. set our static helper field to it.
            if (identity.isLocalPlayer)
            {
                localPlayer = identity;
                identity.connectionToServer = connection;
                identity.OnStartLocalPlayer();
            }
            // identity's isLocalPlayer was set to false.
            // clear our static localPlayer IF (and only IF) it was that one before.
            else if (localPlayer == identity)
            {
                localPlayer = null;
                // TODO set .connectionToServer to null for old local player?
                // since we set it in the above 'if' case too.
            }
        }

        // set up NetworkIdentity flags on the client.
        // needs to be separate from invoking callbacks.
        // cleaner, and some places need to set flags first.
        static void InitializeIdentityFlags(NetworkIdentity identity)
        {
            // initialize flags before invoking callbacks.
            // this way isClient/isLocalPlayer is correct during callbacks.
            // fixes: https://github.com/MirrorNetworking/Mirror/issues/3362
            identity.isClient = true;
            identity.isLocalPlayer = localPlayer == identity;

            // .connectionToServer is only available for local players.
            // set it here, before invoking any callbacks.
            // this way it's available in _all_ callbacks.
            if (identity.isLocalPlayer)
                identity.connectionToServer = connection;
        }

        // invoke NetworkIdentity callbacks on the client.
        // needs to be separate from configuring flags.
        // cleaner, and some places need to set flags first.
        static void InvokeIdentityCallbacks(NetworkIdentity identity)
        {
            // invoke OnStartAuthority
            identity.NotifyAuthority();

            // invoke OnStartClient
            identity.OnStartClient();

            // invoke OnStartLocalPlayer
            if (identity.isLocalPlayer)
                identity.OnStartLocalPlayer();
        }

        // configure flags & invoke callbacks
        static void BootstrapIdentity(NetworkIdentity identity)
        {
            InitializeIdentityFlags(identity);
            InvokeIdentityCallbacks(identity);
        }

        // broadcast ///////////////////////////////////////////////////////////
        static void BroadcastTimeSnapshot()
        {
            Send(new TimeSnapshotMessage(), Channels.Unreliable);
        }

        // make sure Broadcast() is only called every sendInterval.
        // calling it every update() would require too much bandwidth.
        static void Broadcast()
        {
            // joined the world yet?
            if (!connection.isReady) return;

            // nothing to do in host mode. server already knows the state.
            if (NetworkServer.active) return;

            // send time snapshot every sendInterval.
            BroadcastTimeSnapshot();

            // for each entity that the client owns
            foreach (NetworkIdentity identity in connection.owned)
            {
                // make sure it's not null or destroyed.
                // (which can happen if someone uses
                //  GameObject.Destroy instead of
                //  NetworkServer.Destroy)
                if (identity != null)
                {
                    using (NetworkWriterPooled writer = NetworkWriterPool.Get())
                    {
                        // get serialization for this entity viewed by this connection
                        // (if anything was serialized this time)
                        identity.SerializeClient(writer);
                        if (writer.Position > 0)
                        {
                            // send state update message
                            EntityStateMessage message = new EntityStateMessage
                            {
                                netId = identity.netId,
                                payload = writer.ToArraySegment()
                            };
                            Send(message);
                        }
                    }
                }
                // spawned list should have no null entries because we
                // always call Remove in OnObjectDestroy everywhere.
                // if it does have null then we missed something.
                else Debug.LogWarning($"Found 'null' entry in owned list for client. This is unexpected behaviour.");
            }
        }

        // update //////////////////////////////////////////////////////////////
        // NetworkEarlyUpdate called before any Update/FixedUpdate
        // (we add this to the UnityEngine in NetworkLoop)
        internal static void NetworkEarlyUpdate()
        {
            // process all incoming messages first before updating the world
            if (Transport.active != null)
                Transport.active.ClientEarlyUpdate();

            // time snapshot interpolation
            UpdateTimeInterpolation();
        }

        // NetworkLateUpdate called after any Update/FixedUpdate/LateUpdate
        // (we add this to the UnityEngine in NetworkLoop)
        internal static void NetworkLateUpdate()
        {
            // broadcast ClientToServer components while active
            if (active)
            {
                // broadcast every sendInterval.
                // AccurateInterval to avoid update frequency inaccuracy issues:
                // https://github.com/vis2k/Mirror/pull/3153
                //
                // for example, host mode server doesn't set .targetFrameRate.
                // Broadcast() would be called every tick.
                // snapshots might be sent way too often, etc.
                //
                // during tests, we always call Broadcast() though.
                //
                // also important for syncInterval=0 components like
                // NetworkTransform, so they can sync on same interval as time
                // snapshots _but_ not every single tick.
                //
                // Unity 2019 doesn't have Time.timeAsDouble yet
                if (!Application.isPlaying ||
                    AccurateInterval.Elapsed(NetworkTime.localTime, sendInterval, ref lastSendTime))
                {
                    Broadcast();
                }

                UpdateConnectionQuality();
            }

            // Connection Quality //////////////////////////////////////////////////
            // uses 'pragmatic' version based on snapshot interpolation by default.
            void UpdateConnectionQuality()
            {
                // only recalculate every few seconds
                // we don't want to fire Good->Bad->Good->Bad dozens of times per second.
                if (connectionQualityInterval > 0 && NetworkTime.time > lastConnectionQualityUpdate + connectionQualityInterval)
                {
                    lastConnectionQualityUpdate = NetworkTime.time;

                    switch (connectionQualityMethod)
                    {
                        case ConnectionQualityMethod.Simple:
                            connectionQuality = ConnectionQualityHeuristics.Simple(NetworkTime.rtt, NetworkTime.rttVariance);
                            break;
                        case ConnectionQualityMethod.Pragmatic:
                            connectionQuality = ConnectionQualityHeuristics.Pragmatic(initialBufferTime, bufferTime);
                            break;
                    }

                    if (lastConnectionQuality != connectionQuality)
                    {
                        // Invoke the event before assigning the new value so
                        // the event handler can compare old and new values.
                        onConnectionQualityChanged?.Invoke(lastConnectionQuality, connectionQuality);
                        lastConnectionQuality = connectionQuality;
                    }
                }
            }

            // update connections to flush out messages _after_ broadcast
            // local connection?
            if (connection is LocalConnectionToServer localConnection)
            {
                localConnection.Update();
            }
            // remote connection?
            else if (connection is NetworkConnectionToServer remoteConnection)
            {
                // only update things while connected
                if (active && connectState == ConnectState.Connected)
                {
                    // update NetworkTime
                    NetworkTime.UpdateClient();

                    // update connection to flush out batched messages
                    remoteConnection.Update();
                }
            }

            // process all outgoing messages after updating the world
            if (Transport.active != null)
                Transport.active.ClientLateUpdate();
        }

        // destroy /////////////////////////////////////////////////////////////
        /// <summary>Destroys all networked objects on the client.</summary>
        // Note: NetworkServer.CleanupNetworkIdentities does the same on server.
        public static void DestroyAllClientObjects()
        {
            // user can modify spawned lists which causes InvalidOperationException
            // list can modified either in UnSpawnHandler or in OnDisable/OnDestroy
            // we need the Try/Catch so that the rest of the shutdown does not get stopped
            try
            {
                foreach (NetworkIdentity identity in spawned.Values)
                {
                    if (identity != null && identity.gameObject != null)
                    {
                        if (identity.isLocalPlayer)
                            identity.OnStopLocalPlayer();

                        identity.OnStopClient();

                        // NetworkClient.Shutdown calls DestroyAllClientObjects.
                        // which destroys all objects in NetworkClient.spawned.
                        // => NC.spawned contains owned & observed objects
                        // => in host mode, we CAN NOT destroy observed objects.
                        // => that would destroy them other connection's objects
                        //    on the host server, making them disconnect.
                        // https://github.com/vis2k/Mirror/issues/2954
                        bool hostOwned = identity.connectionToServer is LocalConnectionToServer;
                        bool shouldDestroy = !identity.isServer || hostOwned;
                        if (shouldDestroy)
                        {
                            bool wasUnspawned = InvokeUnSpawnHandler(identity.assetId, identity.gameObject);

                            // unspawned objects should be reset for reuse later.
                            if (wasUnspawned)
                            {
                                identity.ResetState();
                            }
                            // without unspawn handler, we need to disable/destroy.
                            else
                            {
                                // scene objects are reset and disabled.
                                // they always stay in the scene, we don't destroy them.
                                if (identity.sceneId != 0)
                                {
                                    identity.ResetState();
                                    identity.gameObject.SetActive(false);
                                }
                                // spawned objects are destroyed
                                else
                                {
                                    GameObject.Destroy(identity.gameObject);
                                }
                            }
                        }
                    }
                }
                spawned.Clear();
                connection?.owned.Clear();
            }
            catch (InvalidOperationException e)
            {
                Debug.LogException(e);
                Debug.LogError("Could not DestroyAllClientObjects because spawned list was modified during loop, make sure you are not modifying NetworkIdentity.spawned by calling NetworkServer.Destroy or NetworkServer.Spawn in OnDestroy or OnDisable.");
            }
        }

        static void DestroyObject(uint netId)
        {
            // Debug.Log($"NetworkClient.OnObjDestroy netId: {netId}");
            if (spawned.TryGetValue(netId, out NetworkIdentity identity) && identity != null)
            {
                if (identity.isLocalPlayer)
                    identity.OnStopLocalPlayer();

                identity.OnStopClient();

                // custom unspawn handler for this prefab? (for prefab pools etc.)
                if (InvokeUnSpawnHandler(identity.assetId, identity.gameObject))
                {
                    // reset object after user's handler
                    identity.ResetState();
                }
                // otherwise fall back to default Destroy
                else if (identity.sceneId == 0)
                {
                    // don't call reset before destroy so that values are still set in OnDestroy
                    GameObject.Destroy(identity.gameObject);
                }
                // scene object.. disable it in scene instead of destroying
                else
                {
                    identity.gameObject.SetActive(false);
                    spawnableObjects[identity.sceneId] = identity;
                    // reset for scene objects
                    identity.ResetState();
                }

                // remove from dictionary no matter how it is unspawned
                connection.owned.Remove(identity); // if any
                spawned.Remove(netId);
            }
            //else Debug.LogWarning($"Did not find target for destroy message for {netId}");
        }

        // shutdown ////////////////////////////////////////////////////////////
        /// <summary>Shutdown the client.</summary>
        // RuntimeInitializeOnLoadMethod -> fast playmode without domain reload
        [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
        public static void Shutdown()
        {
            //Debug.Log("Shutting down client.");

            // objects need to be destroyed before spawners are cleared
            // fixes: https://github.com/MirrorNetworking/Mirror/issues/3334
            DestroyAllClientObjects();

            // calls prefabs.Clear();
            // calls spawnHandlers.Clear();
            // calls unspawnHandlers.Clear();
            ClearSpawners();

            spawned.Clear();
            connection?.owned.Clear();
            handlers.Clear();
            spawnableObjects.Clear();

            // IMPORTANT: do NOT call NetworkIdentity.ResetStatics() here!
            // calling StopClient() in host mode would reset nextNetId to 1,
            // causing next connection to have a duplicate netId accidentally.
            // => see also: https://github.com/vis2k/Mirror/issues/2954
            //NetworkIdentity.ResetStatics();
            // => instead, reset only the client sided statics.
            NetworkIdentity.ResetClientStatics();

            // disconnect the client connection.
            // we do NOT call Transport.Shutdown, because someone only called
            // NetworkClient.Shutdown. we can't assume that the server is
            // supposed to be shut down too!
            if (Transport.active != null)
                Transport.active.ClientDisconnect();

            // reset statics
            connectState = ConnectState.None;
            connection = null;
            localPlayer = null;
            ready = false;
            isSpawnFinished = false;
            isLoadingScene = false;
            lastSendTime = 0;

            unbatcher = new Unbatcher();

            // clear events. someone might have hooked into them before, but
            // we don't want to use those hooks after Shutdown anymore.
            OnConnectedEvent = null;
            OnDisconnectedEvent = null;
            OnErrorEvent = null;
            OnTransportExceptionEvent = null;
        }

        // GUI /////////////////////////////////////////////////////////////////
        // called from NetworkManager to display timeline interpolation status.
        // useful to indicate catchup / slowdown / dynamic adjustment etc.
        public static void OnGUI()
        {
            // only if in world
            if (!ready) return;

            GUILayout.BeginArea(new Rect(10, 5, 1020, 50));

            GUILayout.BeginHorizontal("Box");
            GUILayout.Label("Snapshot Interp.:");
            // color while catching up / slowing down
            if (localTimescale > 1) GUI.color = Color.green; // green traffic light = go fast
            else if (localTimescale < 1) GUI.color = Color.red;   // red traffic light = go slow
            else GUI.color = Color.white;
            GUILayout.Box($"timeline: {localTimeline:F2}");
            GUILayout.Box($"buffer: {snapshots.Count}");
            GUILayout.Box($"DriftEMA: {NetworkClient.driftEma.Value:F2}");
            GUILayout.Box($"DelTimeEMA: {NetworkClient.deliveryTimeEma.Value:F2}");
            GUILayout.Box($"timescale: {localTimescale:F2}");
            GUILayout.Box($"BTM: {NetworkClient.bufferTimeMultiplier:F2}"); // current dynamically adjusted multiplier
            GUILayout.Box($"RTT: {NetworkTime.rtt * 1000:F0}ms");
            GUILayout.Box($"PredErrUNADJ: {NetworkTime.predictionErrorUnadjusted * 1000:F0}ms");
            GUILayout.Box($"PredErrADJ: {NetworkTime.predictionErrorAdjusted * 1000:F0}ms");
            GUILayout.EndHorizontal();

            GUILayout.EndArea();
        }
    }
}
