// Copyright (c) 2026 EzAdmin. All rights reserved.
// Licensed under the EzAdmin Client License. See LICENSE-CLIENT.txt.

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using PlayFab;
using PlayFab.ClientModels;
using UnityEngine;

namespace EzAdmin.Client
{

    public sealed class EzAdminClient : MonoBehaviour
    {

        public static class CloudFunctions
        {
            public const string RedeemLinkCode = "EzAdminRedeemLinkCode";
            public const string GetLinkStatus = "EzAdminGetLinkStatus";
        }

        public const string Version = "2.13.0";

        public static EzAdminClient Instance { get; private set; }

        [EzAdminGroup("Behaviour")]
        [Tooltip("Keep this object alive across scene loads. Turn off if you place one " +
                 "per scene instead of in a bootstrap scene.")]
        [SerializeField] private bool _persistAcrossScenes = true;

        [Tooltip("Log EzAdmin activity to the console. Editor only either way.")]
        [SerializeField] private bool _verboseLogging;

        [Tooltip("Show /announce messages from Discord on screen (a banner, or a floating card in VR) when nothing " +
                 "else in your game shows them. Turn off if your game shows EzAdminClient.Announcement itself.")]
        [SerializeField] private bool _showAnnouncementBanner = true;

        private const string BakedAddress = "https://ezadmin.online";
        public const string DefaultAddress = "https://ezadmin.online";
        public static string SiteAddress => (string.IsNullOrEmpty(BakedAddress) ? DefaultAddress : BakedAddress).TrimEnd('/');
        private string _ezAdminAddress = SiteAddress;

        [EzAdminGroup("Signing in")]
        [Tooltip("Sign the player in to PlayFab with this device when your game does not do it itself. " +
                 "Turn OFF if your game has its own PlayFab login (Meta, Steam, email...).")]
        [SerializeField] private bool _signInIfNeeded = true;

        [Tooltip("How long to wait for your own login code before signing in for you.")]
        [SerializeField, Range(0f, 30f)] private float _signInAfterSeconds = 2f;

        public enum KickAction { QuitGame, LoadScene, EventOnly }

        [EzAdminGroup("When a moderator kicks this player")]
        [Tooltip("QuitGame closes the game. LoadScene loads Kick Scene (e.g. your main menu). " +
                 "EventOnly does nothing itself: handle EzAdminClient.Kicked in your code.")]
        [SerializeField] private KickAction _onKick = KickAction.QuitGame;
        [SerializeField] private string _kickScene = "";

        [EzAdminGroup("Require a connection to play")]
        [Tooltip("Players must stay connected to EzAdmin to keep playing. If a player blocks EzAdmin (a firewall, " +
                 "or pulling their internet to dodge the anti tamper), they are kicked after the grace time below. " +
                 "Off by default, so a short internet drop never removes an honest player.")]
        [SerializeField] private bool _requireConnection = false;
        [Tooltip("Seconds EzAdmin can be unreachable before the player is kicked. Keep it well above one heartbeat.")]
        [SerializeField, Range(30f, 600f)] private float _connectionGrace = 120f;

        private float _lastGoodBeat;

        public static event Action<string> Kicked;

        public static event Action<bool, int> MuteChanged;

        public static bool IsShadowMuted => ShadowMuteUntil > (DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalSeconds;

        public static long ShadowMuteUntil { get; private set; }

        public static event Action<bool> ShadowMuteChanged;

        private static void SetShadowMute(long until)
        {
            bool was = IsShadowMuted;
            ShadowMuteUntil = until;
            if (was != IsShadowMuted) ShadowMuteChanged?.Invoke(IsShadowMuted);
        }

        public static event Action ItemsChanged;

        public static event Action<string> SignedIn;

        public static event Action<string> Announcement;

        public static string LastAnnouncement { get; private set; } = "";

        public static event Action<bool> FreezeChanged;
        public static bool IsFrozen { get; private set; }

        public static string ServerName { get; private set; } = "";
        public static string CatalogVersion { get; private set; } = "";
        public static Sprite ServerIcon { get; private set; }
        public static event Action ServerBrandChanged;
        private static string _serverIconUrl = "";

        private void ApplyServerBrand(string name, string iconUrl)
        {
            bool changed = false;
            if (!string.IsNullOrEmpty(name) && name != ServerName)
            {
                ServerName = name;
                changed = true;
            }
            iconUrl = iconUrl ?? "";
            if (!string.IsNullOrEmpty(iconUrl) && iconUrl != _serverIconUrl)
            {
                _serverIconUrl = iconUrl;
                StartCoroutine(DownloadServerIcon(iconUrl));
            }
            else if (changed)
            {
                ServerBrandChanged?.Invoke();
            }
        }

        private System.Collections.IEnumerator DownloadServerIcon(string url)
        {
            using (var request = UnityEngine.Networking.UnityWebRequestTexture.GetTexture(url))
            {
                yield return request.SendWebRequest();
                if (request.result != UnityEngine.Networking.UnityWebRequest.Result.Success) yield break;
                var texture = UnityEngine.Networking.DownloadHandlerTexture.GetContent(request);
                if (texture == null) yield break;
                ServerIcon = Sprite.Create(texture, new Rect(0, 0, texture.width, texture.height),
                                           new Vector2(0.5f, 0.5f), 100f);
                ServerBrandChanged?.Invoke();
            }
        }

        public static bool IsStaff { get; private set; }
        public static event Action<bool> StaffChanged;

        private static void SetStaff(bool staff)
        {
            if (staff == IsStaff) return;
            IsStaff = staff;
            StaffChanged?.Invoke(staff);
        }

        public static bool CanUseStaffTools { get; private set; }
        public static event Action<bool> StaffToolsChanged;

        private static void SetStaffTools(bool allowed)
        {
            if (allowed == CanUseStaffTools) return;
            CanUseStaffTools = allowed;
            StaffToolsChanged?.Invoke(allowed);
        }

        public void LogStaffTool(string tool, bool? on = null, string targetName = "")
        {
            if (string.IsNullOrEmpty(_ezAdminAddress) || string.IsNullOrEmpty(_playToken) || string.IsNullOrEmpty(tool))
                return;
            string json = "{\"token\":" + Quote(_playToken) + ",\"tool\":" + Quote(tool)
                          + ",\"target\":" + Quote(targetName ?? "")
                          + (on.HasValue ? ",\"on\":" + (on.Value ? "true" : "false") : "") + "}";
            StartCoroutine(PostJson("/unity/v1/stafftool", json, _ => { }));
        }

        private static string Quote(string text)
        {
            var builder = new System.Text.StringBuilder("\"");
            foreach (char c in text)
            {
                if (c == '"' || c == '\\') builder.Append('\\').Append(c);
                else if (c < ' ') builder.Append(' ');
                else builder.Append(c);
            }
            return builder.Append('"').ToString();
        }

        public static event Action<int, string> ShutdownScheduled;

        public static event Action EventChanged;

        public static string EventName { get; private set; } = "";

        public static float Multiplier { get; private set; } = 1f;

        public static long EventEndsAt { get; private set; }

        private long _shutdownAt;

        private string _playToken;

        public static string PlayToken => Instance != null ? Instance._playToken ?? "" : "";

        public static string VerifyAddress => SiteAddress + "/unity/v1/play/verify";

        private string _acNonce = "";
        private string _room = "";
        private long _announceAfter = -1;
        private long _shownAnnounceId = -1;
        private string _shownAnnounceText = "";
        private float _shownAnnounceAt;
        private float _beatSeconds = 30f;
        private string _lastNetworkError;
        private string _signInError;

        public string ConnectionProblem { get; private set; } = "Starting up…";

        public static event Action<string> ConnectionChanged;

        private bool _cloudScriptMissing;

        private void SetProblem(string problem)
        {
            if (problem == null && _cloudScriptMissing) problem = CloudScriptMissing;
            if (problem == ConnectionProblem) return;
            ConnectionProblem = problem;
            if (problem == null)
            {
                Log("Connected to EzAdmin.");
            }
            else
            {
                Debug.LogWarning("EzAdmin: " + problem, this);
            }
            ConnectionChanged?.Invoke(problem);
        }

        private string NetworkProblem()
        {
            string error = _lastNetworkError ?? "";
            if (error.IndexOf("Insecure", StringComparison.OrdinalIgnoreCase) >= 0)
            {
                return "Unity is blocking the plain http:// address " + _ezAdminAddress + ". Use your https:// address, " +
                       "or for testing set Project Settings › Player › Other Settings › Allow downloads over HTTP " +
                       "to Always allowed." + Fix("unity-http-blocked");
            }
            string where = _ezAdminAddress.TrimEnd('/');
            string local = where.Contains("localhost") || where.Contains("127.0.0.1")
                ? " localhost only works on the PC EzAdmin runs on: a headset or phone needs your online address."
                : "";
            return "Cannot reach EzAdmin at " + where + " (" + (string.IsNullOrEmpty(error) ? "no answer" : error) +
                   "). Is EzAdmin running, and is the address right?" + local + Fix("unity-cannot-reach");
        }

        private string Fix(string problemId)
        {
            string site = (_ezAdminAddress ?? "").Trim().TrimEnd('/');
            return string.IsNullOrEmpty(site) ? "" : " How to fix: " + site + "/troubleshooting.html#" + problemId;
        }
        private bool _refusalLogged;

        private bool _requestInFlight;

        public bool IsBusy => _requestInFlight;

        public bool IsSignedIn => PlayFabClientAPI.IsClientLoggedIn();

        public bool CanLinkWithDiscord => !string.IsNullOrEmpty(_ezAdminAddress);

        public void LinkWithDiscordToken(string discordAccessToken, Action<bool, string> onComplete)
        {
            if (onComplete == null)
            {
                onComplete = delegate { };
            }

            if (!IsSignedIn)
            {
                onComplete(false, "You are not signed in yet. Try again in a moment.");
                return;
            }

            if (!CanLinkWithDiscord)
            {
                onComplete(false, "Linking with Discord is not set up in this game.");
                return;
            }

            if (string.IsNullOrEmpty(discordAccessToken))
            {
                onComplete(false, "Discord did not sign you in.");
                return;
            }

            if (_requestInFlight)
            {
                onComplete(false, "Still linking - one moment.");
                return;
            }

            _requestInFlight = true;
            Log("Linking with Discord.");
            StartCoroutine(PostDiscordLink(discordAccessToken, onComplete));
        }

        private System.Collections.IEnumerator PostDiscordLink(string discordAccessToken,
                                                               Action<bool, string> onComplete)
        {
            var body = new DiscordLinkRequest
            {
                titleId = PlayFabSettings.staticSettings.TitleId,
                sessionTicket = PlayFabSettings.staticPlayer.ClientSessionTicket,
                discordToken = discordAccessToken
            };

            string url = _ezAdminAddress.TrimEnd('/') + "/unity/v1/discord-link";
            byte[] payload = System.Text.Encoding.UTF8.GetBytes(JsonUtility.ToJson(body));

            using (var request = new UnityEngine.Networking.UnityWebRequest(url, "POST"))
            {
                request.uploadHandler = new UnityEngine.Networking.UploadHandlerRaw(payload);
                request.downloadHandler = new UnityEngine.Networking.DownloadHandlerBuffer();
                request.SetRequestHeader("Content-Type", "application/json");
                request.timeout = 20;

                yield return request.SendWebRequest();
                _requestInFlight = false;

                DiscordLinkResponse response = null;
                try
                {
                    response = JsonUtility.FromJson<DiscordLinkResponse>(request.downloadHandler.text);
                }
                catch (Exception)
                {
                    response = null;
                }

                if (response == null)
                {
                    onComplete(false, "Could not reach EzAdmin. Check your connection and try again.");
                    yield break;
                }

                Log("Discord link result: " + response.ok);
                string message = response.message;
                if (string.IsNullOrEmpty(message))
                {
                    message = response.ok ? "Linked with Discord." : "Linking with Discord did not work.";
                }

                onComplete(response.ok, message);
            }
        }

        private void Awake()
        {
            if (Instance != null && Instance != this)
            {
                Destroy(gameObject);
                return;
            }

            Instance = this;

            if (_persistAcrossScenes)
            {
                DontDestroyOnLoad(gameObject);
            }
        }

        [EzAdminGroup("Error reports")]
        [Tooltip("Send this game's errors and exceptions to EzAdmin, grouped on the dashboard. Only used when " +
                 "Error reports is switched on for your server.")]
        [SerializeField] private bool _sendErrorReports = true;

        private readonly List<ErrorItem> _errorQueue = new List<ErrorItem>();
        private readonly HashSet<string> _errorsSent = new HashSet<string>();
        private readonly object _errorLock = new object();

        private void OnLogMessage(string condition, string stackTrace, LogType type)
        {
            if (type != LogType.Exception && type != LogType.Error) return;
            if (condition != null && condition.StartsWith("EzAdmin:")) return;
            string first = string.IsNullOrEmpty(stackTrace) ? "" : stackTrace.Split('\n')[0];
            string key = (condition ?? "") + "|" + first;
            lock (_errorLock)
            {
                if (_errorsSent.Count > 500 || !_errorsSent.Add(key) || _errorQueue.Count >= 20) return;
                _errorQueue.Add(new ErrorItem
                {
                    message = (condition ?? "").Length > 300 ? condition.Substring(0, 300) : (condition ?? ""),
                    stack = first.Length > 300 ? first.Substring(0, 300) : first,
                    version = Application.version
                });
            }
        }

        private System.Collections.IEnumerator SendErrors()
        {
            while (true)
            {
                yield return new WaitForSecondsRealtime(60f);
                if (string.IsNullOrEmpty(_playToken)) continue;
                ErrorItem[] batch;
                lock (_errorLock)
                {
                    if (_errorQueue.Count == 0) continue;
                    batch = _errorQueue.ToArray();
                    _errorQueue.Clear();
                }
                var body = new ErrorBatch { token = _playToken, errors = batch };
                yield return PostJson("/unity/v1/errors", JsonUtility.ToJson(body), _ => { });
            }
        }

        public void SetRoom(string roomName)
        {
            _room = string.IsNullOrEmpty(roomName) ? "" : (roomName.Length > 60 ? roomName.Substring(0, 60) : roomName);
        }

        public string Room => _room;

        public void LookupPlayer(string nameOrPlayFabId, Action<bool, LookupResult> onComplete)
        {
            if (string.IsNullOrEmpty(_ezAdminAddress) || string.IsNullOrEmpty(_playToken) || string.IsNullOrEmpty(nameOrPlayFabId))
            {
                onComplete?.Invoke(false, new LookupResult { message = "Not connected to EzAdmin yet." });
                return;
            }
            var body = new LookupRequest { token = _playToken, query = nameOrPlayFabId };
            StartCoroutine(PostJson("/unity/v1/lookup", JsonUtility.ToJson(body), text =>
            {
                var reply = Parse<LookupResult>(text) ?? new LookupResult { message = "EzAdmin did not answer." };
                onComplete?.Invoke(reply.ok, reply);
            }));
        }

        public void ReportAchievement(string badgeId, string displayName, Action<bool> onComplete = null)
        {
            if (string.IsNullOrEmpty(_ezAdminAddress) || string.IsNullOrEmpty(_playToken))
            {
                onComplete?.Invoke(false);
                return;
            }
            var body = new AchievementRequest { token = _playToken, badge = badgeId ?? "", name = displayName ?? "" };
            StartCoroutine(PostJson("/unity/v1/achievement", JsonUtility.ToJson(body), text =>
            {
                var reply = Parse<SimpleResponse>(text);
                onComplete?.Invoke(reply != null && reply.ok);
            }));
        }

        private void Start()
        {
            if (_sendErrorReports && CanLinkWithDiscord)
            {
                Application.logMessageReceivedThreaded += OnLogMessage;
                StartCoroutine(SendErrors());
            }
            if (_signInIfNeeded)
            {
                StartCoroutine(SignInIfNeeded());
            }

            if (CanLinkWithDiscord)
            {
                StartCoroutine(PlaytimeLoop());
                StartCoroutine(ItemWatch());
            }
            else
            {
                Debug.LogWarning("EzAdmin: the EzAdmin address on EzAdminClient is empty, so nothing can reach " +
                                 "EzAdmin. Window › EzAdmin › Setup Wizard sets it.", this);
            }
        }

        private System.Collections.IEnumerator SignInIfNeeded()
        {
            yield return new WaitForSecondsRealtime(_signInAfterSeconds);
            if (IsSignedIn)
            {
                yield break;
            }
            if (string.IsNullOrEmpty(PlayFabSettings.staticSettings.TitleId))
            {
                Debug.LogError("EzAdmin: no PlayFab Title ID, so nobody can sign in. Search the Project window for " +
                               "PlayFabSharedSettings and type your Title ID in.", this);
                yield break;
            }
            while (!IsSignedIn)
            {
                bool answered = false;
                PlayFabClientAPI.LoginWithCustomID(new LoginWithCustomIDRequest
                {
                    CustomId = SystemInfo.deviceUniqueIdentifier,
                    CreateAccount = true
                }, result =>
                {
                    answered = true;
                    _signInError = null;
                    Log("Signed in to PlayFab as " + result.PlayFabId);
                    SignedIn?.Invoke(result.PlayFabId);
                }, error =>
                {
                    answered = true;
                    _signInError = SignInAdvice(error);
                    if (_signInError.IndexOf("refused EzAdmin's sign-in", StringComparison.Ordinal) >= 0)
                    {
                        _signInError += Fix("unity-signin-refused");
                    }
                    SetProblem(_signInError);
                    Debug.LogWarning("EzAdmin: PlayFab sign-in failed: " + error.GenerateErrorReport(), this);
                });
                while (!answered) yield return null;
                if (IsSignedIn) yield break;

                yield return new WaitForSecondsRealtime(10f);
            }
        }

        private static string SignInAdvice(PlayFabError error)
        {
            string title = PlayFabSettings.staticSettings.TitleId;
            string said = string.IsNullOrEmpty(error.ErrorMessage) ? error.Error.ToString() : error.ErrorMessage;
            if (said.IndexOf("not authorized", StringComparison.OrdinalIgnoreCase) >= 0)
            {
                return "PlayFab title " + title + " refused EzAdmin's sign-in (\"" + said + "\"): it only lets your " +
                       "game's own login sign players in. Turn OFF Sign In If Needed on EzAdminClient and put EzAdmin " +
                       "in the scene where your login runs: EzAdmin then uses that player.";
            }
            if (said.IndexOf("banned", StringComparison.OrdinalIgnoreCase) >= 0)
            {
                return "PlayFab says this device's account is banned on title " + title + ".";
            }
            return "PlayFab sign-in failed on title " + title + ": " + said;
        }

        private void HandleBeat(BeatResponse reply)
        {
            if (reply == null) return;
            if (reply.muteUntil != 0 || reply.muteCleared)
            {
                double now = (DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalSeconds;
                int left = (int)Math.Max(0, reply.muteUntil - now);
                MuteChanged?.Invoke(left > 0, left);
            }
            if (reply.shadowMuteUntil != 0 || reply.shadowMuteCleared)
            {
                SetShadowMute(reply.shadowMuteCleared ? 0 : reply.shadowMuteUntil);
            }
            if (reply.itemsChanged)
            {
                ItemsChanged?.Invoke();
            }
            if (reply.announceId > 0)
            {
                _announceAfter = reply.announceId;
            }
            if (!string.IsNullOrEmpty(reply.announcement))
            {
                bool sameId = reply.announceId > 0 && reply.announceId == _shownAnnounceId;
                bool sameText = reply.announcement == _shownAnnounceText
                                && Time.realtimeSinceStartup - _shownAnnounceAt < 120f;
                if (!sameId && !sameText)
                {
                    _shownAnnounceId = reply.announceId;
                    _shownAnnounceText = reply.announcement;
                    _shownAnnounceAt = Time.realtimeSinceStartup;
                    LastAnnouncement = reply.announcement;
                    Log("Announcement: " + reply.announcement);
                    Announcement?.Invoke(reply.announcement);

                    if (_showAnnouncementBanner && EzAdminLinkScreen.ShowingAnnouncements == 0)
                    {
                        var shown = reply.announceStyle ?? new EzAdminAnnouncement();
                        shown.text = reply.announcement;
                        EzAdminAnnouncer.Show(shown);
                    }
                }
            }
            SetStaff(reply.staff);
            SetStaffTools(reply.staffTools);
            EzAdminGame.Offer(reply.liveopsRev);
            if (reply.freezeChanged)
            {
                IsFrozen = reply.frozen;
                FreezeChanged?.Invoke(reply.frozen);
            }
            string eventName = reply.eventName ?? "";
            float multiplier = reply.eventMultiplier > 0 ? reply.eventMultiplier : 1f;
            if (eventName != EventName || !Mathf.Approximately(multiplier, Multiplier))
            {
                EventName = eventName;
                Multiplier = string.IsNullOrEmpty(eventName) ? 1f : multiplier;
                EventEndsAt = string.IsNullOrEmpty(eventName) ? 0 : reply.eventEndsAt;
                EventChanged?.Invoke();
            }
            if (reply.shutdownAt > 0 && reply.shutdownAt != _shutdownAt)
            {
                _shutdownAt = reply.shutdownAt;
                StartCoroutine(ShutdownCountdown(reply.shutdownAt,
                    string.IsNullOrEmpty(reply.shutdownMessage) ? "The game is restarting." : reply.shutdownMessage));
            }
            if (reply.runCloudScript) RunCloudScriptApply();
            if (!string.IsNullOrEmpty(reply.kick))
            {
                DoKick(reply.kick);
            }
        }

        private bool _applyingCloudScript;

        private void RunCloudScriptApply()
        {
            if (_applyingCloudScript || !PlayFabClientAPI.IsClientLoggedIn()) return;
            _applyingCloudScript = true;
            PlayFabClientAPI.ExecuteCloudScript(new ExecuteCloudScriptRequest
            {
                FunctionName = "EzAdminApply",
                GeneratePlayStreamEvent = false
            }, result =>
            {
                _applyingCloudScript = false;
                ItemsChanged?.Invoke();
            }, error =>
            {
                _applyingCloudScript = false;
            });
        }

        public const float ItemCheckSeconds = 5f;

        private System.Collections.IEnumerator ItemWatch()
        {
            var wait = new WaitForSecondsRealtime(ItemCheckSeconds);
            while (true)
            {
                yield return wait;
                if (string.IsNullOrEmpty(_playToken) || !Application.isFocused) continue;
                bool changed = false;
                yield return PostJson("/unity/v1/play/items-check", JsonUtility.ToJson(new ItemsRequest { token = _playToken }),
                    text =>
                    {
                        var reply = Parse<ItemsCheckResponse>(text);
                        changed = reply != null && reply.ok && reply.changed;
                    });
                if (changed) ItemsChanged?.Invoke();
            }
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class ItemsCheckResponse
        {
            [UnityEngine.Scripting.Preserve] public bool ok;
            [UnityEngine.Scripting.Preserve] public bool changed;
        }

        private bool _codeSent;
        private int _codeBeats;
        private const int CodeResendBeats = 20;
        private bool _attesting;

        private void StartAttestation(string nonce)
        {
            if (_attesting || string.IsNullOrEmpty(nonce) || !EzAdminAttestation.Available) return;
            _attesting = true;
            EzAdminAttestation.Request(nonce, (attestation, error) =>
            {
                if (attestation == null && error == null || string.IsNullOrEmpty(_playToken))
                {
                    _attesting = false;
                    return;
                }
                var body = new AttestRequest { token = _playToken, attestation = attestation ?? "", error = error ?? "" };
                StartCoroutine(PostJson("/unity/v1/play/attest", JsonUtility.ToJson(body), text =>
                {
                    _attesting = false;
                    var reply = Parse<AttestResponse>(text);
                    if (reply == null) return;
                    if (!string.IsNullOrEmpty(reply.kick)) DoKick(reply.kick);
                    else if (!string.IsNullOrEmpty(reply.attestNonce)) StartCoroutine(AttestLater(reply.attestNonce));
                }));
            });
        }

        private System.Collections.IEnumerator AttestLater(string nonce)
        {
            yield return new WaitForSecondsRealtime(20f);
            StartAttestation(nonce);
        }

        private void DoKick(string message)
        {
            Debug.Log("EzAdmin: removed from the game: " + message);
            Kicked?.Invoke(message);
            switch (_onKick)
            {
                case KickAction.QuitGame:
                    StartCoroutine(QuitSoon());
                    break;
                case KickAction.LoadScene:
                    if (!string.IsNullOrEmpty(_kickScene))
                    {
                        UnityEngine.SceneManagement.SceneManager.LoadScene(_kickScene);
                    }
                    break;
            }
        }

        private static double UnixNow()
        {
            return (DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalSeconds;
        }

        private System.Collections.IEnumerator ShutdownCountdown(long at, string message)
        {
            int left = (int)Math.Max(0, at - UnixNow());
            ShutdownScheduled?.Invoke(left, message);
            int[] marks = { 300, 60, 30, 10 };
            bool first = true;
            foreach (int mark in marks)
            {
                left = (int)Math.Max(0, at - UnixNow());
                if (left < mark && !first) continue;
                if (left > mark) yield return new WaitForSecondsRealtime(left - mark);
                if (_shutdownAt != at) yield break;
                left = (int)Math.Max(0, at - UnixNow());
                if (left <= 0) break;
                string when = left >= 60 ? (left / 60) + " minute" + (left >= 120 ? "s" : "") : left + " seconds";
                LastAnnouncement = message + " Closing in " + when + ".";
                Announcement?.Invoke(LastAnnouncement);
                first = false;
            }
            left = (int)Math.Max(0, at - UnixNow());
            if (left > 0) yield return new WaitForSecondsRealtime(left);
            if (_shutdownAt == at) DoKick(message);
        }

        private System.Collections.IEnumerator QuitSoon()
        {

            yield return new WaitForSecondsRealtime(3f);
#if UNITY_EDITOR
            UnityEditor.EditorApplication.isPlaying = false;
#else
            Application.Quit();
#endif
        }

        private void OnDestroy()
        {
            Application.logMessageReceivedThreaded -= OnLogMessage;
            if (Instance == this)
            {
                Instance = null;
            }
        }

        private void OnApplicationQuit()
        {
            if (string.IsNullOrEmpty(_playToken))
            {
                return;
            }

            var body = new PlayBeatRequest { token = _playToken, ending = true };
            var request = new UnityEngine.Networking.UnityWebRequest(
                _ezAdminAddress.TrimEnd('/') + "/unity/v1/play/beat", "POST");
            request.uploadHandler = new UnityEngine.Networking.UploadHandlerRaw(
                System.Text.Encoding.UTF8.GetBytes(JsonUtility.ToJson(body)));
            request.SetRequestHeader("Content-Type", "application/json");
            request.SendWebRequest();
        }

        private System.Collections.IEnumerator PlaytimeLoop()
        {
            float waited = 0f;
            SetProblem("Signing in to PlayFab…");
            while (!IsSignedIn)
            {
                yield return new WaitForSecondsRealtime(1f);
                waited += 1f;
                if (waited >= 15f)
                {
                    SetProblem(_signInError ?? (string.IsNullOrEmpty(PlayFabSettings.staticSettings.TitleId)
                        ? "Not signed in to PlayFab: there is no Title ID. Search the Project window for " +
                          "PlayFabSharedSettings and type yours in." + Fix("unity-no-title-id")
                        : _signInIfNeeded
                            ? "Not signed in to PlayFab yet (title " + PlayFabSettings.staticSettings.TitleId + "). " +
                              "Check the Console for a PlayFab sign-in error." + Fix("unity-own-login-slow")
                            : "Not signed in to PlayFab: your game's own login has not finished. Turn on Sign In If " +
                              "Needed on EzAdminClient if your game has no login." + Fix("unity-own-login-slow")));
                }
            }
            SetProblem("Connecting to EzAdmin…");
            RefreshLinkStatus(null);

            while (true)
            {
                if (string.IsNullOrEmpty(_playToken))
                {

                    float fingerprintWait = 0f;
                    while (EzAdminAntiCheat.Present && !EzAdminAntiCheat.FingerprintReady && fingerprintWait < 10f)
                    {
                        yield return new WaitForSecondsRealtime(0.25f);
                        fingerprintWait += 0.25f;
                    }
                    var start = new PlayStartRequest
                    {
                        titleId = PlayFabSettings.staticSettings.TitleId,
                        sessionTicket = PlayFabSettings.staticPlayer.ClientSessionTicket,
                        version = Version,
                        fingerprint = EzAdminAntiCheat.Fingerprint ?? "",
                        platform = Application.platform.ToString(),
                        appVersion = Application.version,
                        acVersion = EzAdminAntiCheat.Present ? 4 : 0,
                        device = EzAdminAntiCheat.DeviceSignals ?? ""
                    };
                    yield return PostJson("/unity/v1/play/start", JsonUtility.ToJson(start), text =>
                    {
                        var reply = Parse<PlayStartResponse>(text);
                        if (reply == null)
                        {
                            SetProblem(NetworkProblem());
                        }
                        else if (!reply.ok)
                        {
                            SetProblem(string.IsNullOrEmpty(reply.message) ? "EzAdmin refused this game." : reply.message);
                        }
                        if (reply != null && reply.ok && !string.IsNullOrEmpty(reply.token))
                        {
                            _playToken = reply.token;
                            _acNonce = reply.acNonce ?? "";
                            EzAdminAntiCheat.Quiet = reply.acQuiet;
                            _codeSent = false;
                            _codeBeats = 0;
                            _lastGoodBeat = Time.realtimeSinceStartup;
                            if (!string.IsNullOrEmpty(reply.attestNonce)) StartAttestation(reply.attestNonce);
                            SetProblem(null);
                            SetShadowMute(reply.shadowMuteUntil);
                            SetStaff(reply.staff);
                            SetStaffTools(reply.staffTools);
                            EzAdminGame.Offer(reply.liveopsRev);
                            if (!string.IsNullOrEmpty(reply.catalog)) CatalogVersion = reply.catalog;
                            ApplyServerBrand(reply.serverName, reply.serverIcon);
                            if (!string.IsNullOrEmpty(reply.kick))
                            {

                                DoKick(reply.kick);
                            }
                            if (reply.beatSeconds > 0)
                            {
                                _beatSeconds = reply.beatSeconds;
                            }
                        }
                        else if (reply != null && !reply.ok && !string.IsNullOrEmpty(reply.message)
                                 && !_refusalLogged)
                        {

                            _refusalLogged = true;
                            Debug.LogWarning("EzAdmin: " + reply.message);
                        }
                    });

                    if (string.IsNullOrEmpty(_playToken))
                    {

                        yield return new WaitForSecondsRealtime(15f);
                        continue;
                    }
                }
                else
                {
                    if (_codeSent && ++_codeBeats >= CodeResendBeats)
                    {
                        _codeSent = false;
                        _codeBeats = 0;
                        EzAdminIntegrity.ClearCodeReport();
                    }
                    string code = _codeSent ? "" : EzAdminIntegrity.CodeReport ?? "";
                    string answer = EzAdminIntegrity.CodeAnswer ?? "";
                    var beat = new PlayBeatRequest { token = _playToken, ending = false, announceAfter = _announceAfter, room = _room,
                                                    acProof = EzAdminAntiCheat.Proof(_acNonce, code), acScans = EzAdminAntiCheat.Scans,
                                                    acFlags = EzAdminAntiCheat.Flags, codeHashes = code, codeAnswer = answer };
                    yield return PostJson("/unity/v1/play/beat", JsonUtility.ToJson(beat), text =>
                    {

                        var reply = Parse<BeatResponse>(text);
                        if (reply != null && !reply.ok)
                        {
                            _playToken = null;
                        }
                        else
                        {
                            if (reply != null) _lastGoodBeat = Time.realtimeSinceStartup;
                            if (reply != null && code.Length > 0) _codeSent = true;
                            if (reply != null && answer.Length > 0) EzAdminIntegrity.AnswerSent(answer);
                            if (reply != null && !string.IsNullOrEmpty(reply.codeChallenge)) EzAdminIntegrity.Answer(reply.codeChallenge);
                            if (reply != null && !string.IsNullOrEmpty(reply.acNonce)) _acNonce = reply.acNonce;
                            if (reply != null && !string.IsNullOrEmpty(reply.attestNonce)) StartAttestation(reply.attestNonce);
                            HandleBeat(reply);
                        }
                    });

                    if (_requireConnection && !string.IsNullOrEmpty(_playToken) && _lastGoodBeat > 0f
                        && Time.realtimeSinceStartup - _lastGoodBeat > _connectionGrace)
                    {
                        DoKick("Lost connection to EzAdmin. You need to be connected to play here.");
                        _lastGoodBeat = Time.realtimeSinceStartup;
                    }
                }

                yield return new WaitForSecondsRealtime(_beatSeconds);
            }
        }

        public void ReportPlayer(string targetPlayFabId, string targetName, string reason,
                                 Action<bool, string> onComplete)
        {
            if (string.IsNullOrEmpty(_ezAdminAddress) || string.IsNullOrEmpty(_playToken))
            {

                onComplete?.Invoke(false, "Reporting is not ready yet. Try again in a moment.");
                return;
            }

            var body = new ReportRequest
            {
                token = _playToken,
                target = targetPlayFabId ?? string.Empty,
                targetName = targetName ?? string.Empty,
                reason = reason ?? string.Empty
            };
            StartCoroutine(PostJson("/unity/v1/report", JsonUtility.ToJson(body), text =>
            {
                var reply = Parse<SimpleResponse>(text);
                if (reply == null)
                {
                    onComplete?.Invoke(false, "Could not reach the server. Try again later.");
                    return;
                }
                onComplete?.Invoke(reply.ok, reply.message ?? string.Empty);
            }));
        }

        public void ViewProfile(string targetPlayFabId, Action<EzAdminProfile> onComplete)
        {
            if (string.IsNullOrEmpty(_ezAdminAddress) || string.IsNullOrEmpty(_playToken))
            {
                onComplete?.Invoke(EzAdminProfile.Unavailable);
                return;
            }

            var body = new ProfileRequest { token = _playToken, targetPlayFabId = targetPlayFabId ?? string.Empty };
            StartCoroutine(PostJson("/unity/v1/profile", JsonUtility.ToJson(body), text =>
            {
                var reply = Parse<ProfileResponse>(text);
                if (reply == null || !reply.ok)
                {
                    onComplete?.Invoke(EzAdminProfile.Unavailable);
                    return;
                }
                onComplete?.Invoke(new EzAdminProfile(reply.linked, reply.visible, reply.discordId,
                    reply.name, reply.avatarUrl, reply.profileUrl));
            }));
        }

        public void ViewProfiles(IList<string> playFabIds, Action<Dictionary<string, EzAdminProfile>> onComplete)
        {
            var result = new Dictionary<string, EzAdminProfile>();
            if (playFabIds == null || playFabIds.Count == 0)
            {
                onComplete?.Invoke(result);
                return;
            }
            if (string.IsNullOrEmpty(_ezAdminAddress) || string.IsNullOrEmpty(_playToken))
            {
                foreach (var id in playFabIds) result[id] = EzAdminProfile.Unavailable;
                onComplete?.Invoke(result);
                return;
            }

            var ids = new List<string>();
            foreach (var id in playFabIds)
            {
                if (!string.IsNullOrEmpty(id) && !ids.Contains(id) && ids.Count < 50) ids.Add(id);
            }
            var body = new ProfilesRequest { token = _playToken, playFabIds = ids.ToArray() };
            StartCoroutine(PostJson("/unity/v1/profiles", JsonUtility.ToJson(body), text =>
            {
                var reply = Parse<ProfilesResponse>(text);
                foreach (var id in playFabIds) result[id] = EzAdminProfile.Unavailable;
                if (reply != null && reply.ok && reply.profiles != null)
                {
                    foreach (var p in reply.profiles)
                    {
                        if (string.IsNullOrEmpty(p.playFabId)) continue;

                        foreach (var asked in playFabIds)
                        {
                            if (string.Equals(asked, p.playFabId, StringComparison.OrdinalIgnoreCase))
                            {
                                result[asked] = new EzAdminProfile(p.linked, p.visible, p.discordId, p.name,
                                                                   p.avatarUrl, p.profileUrl, p.username);
                            }
                        }
                    }
                }
                onComplete?.Invoke(result);
            }));
        }

        public string LinkCode { get; private set; } = "";

        public void OpenLinkPage(Action<bool, string> onComplete = null)
        {
            RequestPage("/unity/v1/link-web", onComplete);
        }

        public void OpenStaffPanel(Action<bool, string> onComplete = null)
        {
            RequestPage("/unity/v1/panel", onComplete);
        }

        private void RequestPage(string path, Action<bool, string> onComplete)
        {
            if (string.IsNullOrEmpty(_ezAdminAddress))
            {
                onComplete?.Invoke(false, "EzAdmin's address is not set on EzAdminClient.");
                return;
            }
            if (string.IsNullOrEmpty(_playToken))
            {
                StartCoroutine(RequestPageWhenConnected(path, onComplete));
                return;
            }
            var body = new PageRequest { token = _playToken };
            StartCoroutine(PostJson(path, JsonUtility.ToJson(body), text =>
            {
                var reply = Parse<PageResponse>(text);
                if (reply == null)
                {
                    onComplete?.Invoke(false, "Could not reach EzAdmin. Check your connection.");
                    return;
                }
                if (!reply.ok || string.IsNullOrEmpty(reply.url))
                {
                    onComplete?.Invoke(false, string.IsNullOrEmpty(reply.message) ? "That did not work." : reply.message);
                    return;
                }
                LinkCode = string.IsNullOrEmpty(reply.code) ? "" : reply.code;
                EzAdminWeb.Open(reply.url);
                onComplete?.Invoke(true, "");
            }));
        }

        public void GetOwnedItems(Action<OwnedItem[]> onComplete)
        {
            if (onComplete == null) return;
            if (string.IsNullOrEmpty(_ezAdminAddress) || string.IsNullOrEmpty(_playToken))
            {
                onComplete(null);
                return;
            }
            var body = new ItemsRequest { token = _playToken };
            StartCoroutine(PostJson("/unity/v1/items", JsonUtility.ToJson(body), text =>
            {
                var reply = Parse<ItemsResponse>(text);
                if (reply != null && !reply.ok && !string.IsNullOrEmpty(reply.message))
                {
                    Debug.LogWarning("EzAdmin: could not read items: " + reply.message);
                }
                onComplete(reply != null && reply.ok ? (reply.items ?? new OwnedItem[0]) : null);
            }));
        }

        private System.Collections.IEnumerator RequestPageWhenConnected(string path, Action<bool, string> onComplete)
        {
            float until = Time.realtimeSinceStartup + 20f;
            while (string.IsNullOrEmpty(_playToken) && Time.realtimeSinceStartup < until)
            {
                yield return new WaitForSecondsRealtime(0.5f);
            }
            if (string.IsNullOrEmpty(_playToken))
            {
                onComplete?.Invoke(false, ConnectionProblem ?? "Could not connect to EzAdmin.");
                yield break;
            }
            RequestPage(path, onComplete);
        }

        public bool IsConnected => !string.IsNullOrEmpty(_playToken);

        public void ReportPurchase(string itemInstanceOrTransactionId, Action<bool, string> onComplete = null)
        {
            if (string.IsNullOrEmpty(itemInstanceOrTransactionId))
            {
                onComplete?.Invoke(false, "Pass the ItemInstanceId (or Economy v2 TransactionId) PlayFab returned.");
                return;
            }
            PostWithToken("/unity/v1/purchase",
                token => JsonUtility.ToJson(new PurchaseRequest { token = token, reference = itemInstanceOrTransactionId }),
                onComplete);
        }

        public void SendCurrency(string toPlayFabId, int amount, string note, Action<bool, string> onComplete)
        {
            if (string.IsNullOrEmpty(toPlayFabId) || amount <= 0)
            {
                onComplete?.Invoke(false, "Choose who to send it to and an amount above 0.");
                return;
            }
            PostWithToken("/unity/v1/pay",
                token => JsonUtility.ToJson(new PayRequest { token = token, to = toPlayFabId, amount = amount, note = note ?? "" }),
                onComplete);
        }

        internal void ReportCheat(string check, string detail)
        {
            PostWithToken("/unity/v1/play/flag",
                token => JsonUtility.ToJson(new FlagRequest { token = token, check = check, detail = detail ?? "" }),
                null);
        }

        internal void ReportPeer(string targetPlayFabId, string check, string detail)
        {
        }

        internal void FetchLiveOps(Action<string> onText)
        {
            if (string.IsNullOrEmpty(_ezAdminAddress) || string.IsNullOrEmpty(_playToken))
            {
                onText?.Invoke("");
                return;
            }
            StartCoroutine(PostJson("/unity/v1/liveops",
                JsonUtility.ToJson(new FlagRequest { token = _playToken }), text => onText?.Invoke(text ?? "")));
        }

        private void PostWithToken(string path, Func<string, string> body, Action<bool, string> onComplete)
        {
            if (string.IsNullOrEmpty(_ezAdminAddress))
            {
                onComplete?.Invoke(false, "EzAdmin's address is not set on EzAdminClient.");
                return;
            }
            StartCoroutine(PostWhenConnected(path, body, onComplete));
        }

        private System.Collections.IEnumerator PostWhenConnected(string path, Func<string, string> body,
                                                                 Action<bool, string> onComplete)
        {
            float waited = 0f;
            while (string.IsNullOrEmpty(_playToken) && waited < 30f)
            {
                yield return new WaitForSecondsRealtime(0.5f);
                waited += 0.5f;
            }
            if (string.IsNullOrEmpty(_playToken))
            {
                onComplete?.Invoke(false, ConnectionProblem ?? "Could not connect to EzAdmin.");
                yield break;
            }
            yield return PostJson(path, body(_playToken), text =>
            {
                var reply = Parse<SimpleResponse>(text);
                if (reply == null)
                    onComplete?.Invoke(false, "Could not reach EzAdmin. Check your connection.");
                else
                    onComplete?.Invoke(reply.ok, string.IsNullOrEmpty(reply.message) ? (reply.ok ? "Done." : "Refused.") : reply.message);
            });
        }

        private System.Collections.IEnumerator PostJson(string path, string json, Action<string> onText)
        {
            string url = _ezAdminAddress.TrimEnd('/') + path;
            using (var request = new UnityEngine.Networking.UnityWebRequest(url, "POST"))
            {
                request.uploadHandler = new UnityEngine.Networking.UploadHandlerRaw(
                    System.Text.Encoding.UTF8.GetBytes(json));
                request.downloadHandler = new UnityEngine.Networking.DownloadHandlerBuffer();
                request.SetRequestHeader("Content-Type", "application/json");
                request.timeout = 20;
                yield return request.SendWebRequest();
                _lastNetworkError = request.result == UnityEngine.Networking.UnityWebRequest.Result.ConnectionError
                    ? request.error : null;
                onText(request.downloadHandler != null ? request.downloadHandler.text : string.Empty);
            }
        }

        [Obsolete("Role data was removed from EzAdmin. This always returns no values; delete the call.")]
        public void GetRoleData(Action<EzAdminValues> onComplete)
        {
            onComplete?.Invoke(EzAdminValues.Empty);
        }

        public const string MuteKey = "EzAdmin_Mute";

        public void GetMuteStatus(Action<bool, int> onComplete)
        {
            if (onComplete == null)
            {
                return;
            }
            if (!IsSignedIn)
            {
                onComplete(false, 0);
                return;
            }
            PlayFabClientAPI.GetUserReadOnlyData(
                new GetUserDataRequest { Keys = new List<string> { MuteKey } },
                result =>
                {
                    UserDataRecord record = null;
                    if (result.Data != null)
                    {
                        result.Data.TryGetValue(MuteKey, out record);
                    }
                    double until = EzAdminValues.Parse(record != null ? record.Value : null).GetNumber("until", 0);
                    double now = (DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalSeconds;
                    int left = (int)Math.Max(0, Math.Floor(until - now));
                    onComplete(left > 0, left);
                },
                error => onComplete(false, 0));
        }

        private static T Parse<T>(string text) where T : class
        {
            if (string.IsNullOrEmpty(text))
            {
                return null;
            }

            try
            {
                return JsonUtility.FromJson<T>(text);
            }
            catch (Exception)
            {
                return null;
            }
        }

        public void RedeemLinkCode(string code, Action<bool, string> onComplete)
        {
            if (onComplete == null)
            {
                onComplete = delegate { };
            }

            string cleaned = EzAdminCode.Normalise(code);

            if (!IsSignedIn)
            {
                onComplete(false, "You are not signed in yet. Try again in a moment.");
                return;
            }

            if (string.IsNullOrEmpty(cleaned))
            {
                onComplete(false, "Enter the " + EzAdminCode.Length +
                                  "-character code from Discord.");
                return;
            }

            if (_requestInFlight)
            {
                onComplete(false, "Still checking your last code — one moment.");
                return;
            }

            _requestInFlight = true;
            Log("Redeeming link code.");

            var request = new ExecuteCloudScriptRequest
            {
                FunctionName = CloudFunctions.RedeemLinkCode,
                FunctionParameter = new { code = cleaned },
                GeneratePlayStreamEvent = true
            };

            PlayFabClientAPI.ExecuteCloudScript(
                request,
                result =>
                {
                    _requestInFlight = false;

                    string scriptError = FirstScriptError(result);
                    if (CloudScriptMissingIn(result))
                    {
                        onComplete(false, CloudScriptMissing);
                        return;
                    }
                    if (!string.IsNullOrEmpty(scriptError))
                    {
                        Log("CloudScript error: " + scriptError);
                        onComplete(false, "The server could not check that code. " +
                                          "Tell a server admin that EzAdmin's CloudScript " +
                                          "reported an error.");
                        return;
                    }

                    var response = Decode<RedeemResponse>(result);
                    if (response == null)
                    {
                        onComplete(false, "The server gave an unexpected reply. " +
                                          "Is the EzAdmin CloudScript installed?");
                        return;
                    }

                    if (_cloudScriptMissing) MarkCloudScript(true);
                    Log("Redeem result: " + response.success);
                    onComplete(response.success, ChooseMessage(response));
                },
                error =>
                {
                    _requestInFlight = false;
                    Log("PlayFab error: " + error.GenerateErrorReport());
                    if (CloudScriptMissingIn(error))
                    {
                        onComplete(false, CloudScriptMissing);
                        return;
                    }
                    onComplete(false, "Could not reach the server. Check your connection " +
                                      "and try again.");
                });
        }

        public void RefreshLinkStatus(Action<bool, string> onComplete)
        {
            if (onComplete == null)
            {
                onComplete = delegate { };
            }

            if (!IsSignedIn)
            {
                onComplete(false, string.Empty);
                return;
            }

            var request = new ExecuteCloudScriptRequest
            {
                FunctionName = CloudFunctions.GetLinkStatus,
                FunctionParameter = new { },
                GeneratePlayStreamEvent = false
            };

            PlayFabClientAPI.ExecuteCloudScript(
                request,
                result =>
                {
                    if (CloudScriptMissingIn(result))
                    {
                        onComplete(false, string.Empty);
                        return;
                    }
                    var response = Decode<StatusResponse>(result);
                    if (response == null)
                    {
                        onComplete(false, string.Empty);
                        return;
                    }
                    if (_cloudScriptMissing) MarkCloudScript(true);

                    onComplete(response.linked, response.discordName ?? string.Empty);
                },
                error =>
                {
                    Log("Status check failed: " + error.GenerateErrorReport());
                    CloudScriptMissingIn(error);
                    onComplete(false, string.Empty);
                });
        }

        private static string ChooseMessage(RedeemResponse response)
        {
            if (!string.IsNullOrEmpty(response.message))
            {
                return response.message;
            }

            return response.success
                ? "Code accepted. Run /link again in Discord to finish."
                : "That code was not accepted.";
        }

        private const string CloudScriptMissing =
            "Couldn't reach the EzAdmin backend: the EzAdmin CloudScript is not installed in this game's PlayFab. " +
            "Developer: upload it from the EzAdmin dashboard (Game files) to PlayFab › Automation › Revisions and deploy it.";

        private static bool LooksMissing(string text)
        {
            if (string.IsNullOrEmpty(text)) return false;
            return text.IndexOf("NotFound", StringComparison.OrdinalIgnoreCase) >= 0 ||
                   text.IndexOf("not found", StringComparison.OrdinalIgnoreCase) >= 0 ||
                   text.IndexOf("No function named", StringComparison.OrdinalIgnoreCase) >= 0 ||
                   text.IndexOf("is not a function", StringComparison.OrdinalIgnoreCase) >= 0;
        }

        private bool CloudScriptMissingIn(ExecuteCloudScriptResult result)
        {
            bool missing = result != null && result.Error != null &&
                           (LooksMissing(result.Error.Error) || LooksMissing(result.Error.Message));
            if (missing) MarkCloudScript(false);
            return missing;
        }

        private void MarkCloudScript(bool present)
        {
            _cloudScriptMissing = !present;
            if (!present) SetProblem(CloudScriptMissing);
            else if (ConnectionProblem == CloudScriptMissing) SetProblem(string.IsNullOrEmpty(_playToken) ? "Connecting to EzAdmin…" : null);
        }

        private bool CloudScriptMissingIn(PlayFabError error)
        {
            bool missing = error != null && (error.Error == PlayFabErrorCode.CloudScriptNotFound ||
                                             LooksMissing(error.ErrorMessage));
            if (missing) MarkCloudScript(false);
            return missing;
        }

        private static string FirstScriptError(ExecuteCloudScriptResult result)
        {
            if (result == null || result.Error == null)
            {
                return string.Empty;
            }

            return string.IsNullOrEmpty(result.Error.Message)
                ? result.Error.Error
                : result.Error.Message;
        }

        private static T Decode<T>(ExecuteCloudScriptResult result) where T : class
        {
            if (result == null || result.FunctionResult == null)
            {
                return null;
            }

            try
            {
                string json = PlayFab.Json.PlayFabSimpleJson.SerializeObject(result.FunctionResult);
                return PlayFab.Json.PlayFabSimpleJson.DeserializeObject<T>(json);
            }
            catch (Exception)
            {
                return null;
            }
        }

        [System.Diagnostics.Conditional("UNITY_EDITOR")]
        private void Log(string message)
        {
            if (_verboseLogging)
            {
                Debug.Log("[EzAdmin] " + message, this);
            }
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class RedeemResponse
        {
            [UnityEngine.Scripting.Preserve] public bool success;
            [UnityEngine.Scripting.Preserve] public string message;
            [UnityEngine.Scripting.Preserve] public int remaining;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class StatusResponse
        {
            [UnityEngine.Scripting.Preserve] public bool linked;
            [UnityEngine.Scripting.Preserve] public string discordName;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class DiscordLinkRequest
        {
            [UnityEngine.Scripting.Preserve] public string titleId;
            [UnityEngine.Scripting.Preserve] public string sessionTicket;
            [UnityEngine.Scripting.Preserve] public string discordToken;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class DiscordLinkResponse
        {
            [UnityEngine.Scripting.Preserve] public bool ok;
            [UnityEngine.Scripting.Preserve] public string message;
            [UnityEngine.Scripting.Preserve] public string discordName;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class PlayStartRequest
        {
            [UnityEngine.Scripting.Preserve] public string titleId;
            [UnityEngine.Scripting.Preserve] public string sessionTicket;
            [UnityEngine.Scripting.Preserve] public string version;
            [UnityEngine.Scripting.Preserve] public string fingerprint;
            [UnityEngine.Scripting.Preserve] public string platform;
            [UnityEngine.Scripting.Preserve] public string appVersion;
            [UnityEngine.Scripting.Preserve] public int acVersion;
            [UnityEngine.Scripting.Preserve] public string device = "";
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class FlagRequest
        {
            [UnityEngine.Scripting.Preserve] public string token;
            [UnityEngine.Scripting.Preserve] public string check;
            [UnityEngine.Scripting.Preserve] public string detail;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class PurchaseRequest
        {
            [UnityEngine.Scripting.Preserve] public string token;
            [UnityEngine.Scripting.Preserve] public string reference;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class PayRequest
        {
            [UnityEngine.Scripting.Preserve] public string token;
            [UnityEngine.Scripting.Preserve] public string to;
            [UnityEngine.Scripting.Preserve] public int amount;
            [UnityEngine.Scripting.Preserve] public string note;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        public class LookupResult
        {

            [UnityEngine.Scripting.Preserve] public bool ok;

            [UnityEngine.Scripting.Preserve] public string message;

            [UnityEngine.Scripting.Preserve] public string name;

            [UnityEngine.Scripting.Preserve] public string[] lines;

            [UnityEngine.Scripting.Preserve] public bool online;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class SimpleResponse
        {
            [UnityEngine.Scripting.Preserve] public bool ok;
            [UnityEngine.Scripting.Preserve] public string message;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class ReportRequest
        {
            [UnityEngine.Scripting.Preserve] public string token;
            [UnityEngine.Scripting.Preserve] public string target;
            [UnityEngine.Scripting.Preserve] public string targetName;
            [UnityEngine.Scripting.Preserve] public string reason;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class ErrorItem
        {
            [UnityEngine.Scripting.Preserve] public string message;
            [UnityEngine.Scripting.Preserve] public string stack;
            [UnityEngine.Scripting.Preserve] public string version;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class ErrorBatch
        {
            [UnityEngine.Scripting.Preserve] public string token;
            [UnityEngine.Scripting.Preserve] public ErrorItem[] errors;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class AchievementRequest
        {
            [UnityEngine.Scripting.Preserve] public string token;
            [UnityEngine.Scripting.Preserve] public string badge;
            [UnityEngine.Scripting.Preserve] public string name;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class PlayStartResponse
        {
            [UnityEngine.Scripting.Preserve] public bool ok;
            [UnityEngine.Scripting.Preserve] public string token;
            [UnityEngine.Scripting.Preserve] public float beatSeconds;
            [UnityEngine.Scripting.Preserve] public string message;
            [UnityEngine.Scripting.Preserve] public string kick;
            [UnityEngine.Scripting.Preserve] public bool staff;
            [UnityEngine.Scripting.Preserve] public bool staffTools;
            [UnityEngine.Scripting.Preserve] public long liveopsRev;
            [UnityEngine.Scripting.Preserve] public long shadowMuteUntil;
            [UnityEngine.Scripting.Preserve] public string acNonce;
            [UnityEngine.Scripting.Preserve] public bool acQuiet;
            [UnityEngine.Scripting.Preserve] public string attestNonce;
            [UnityEngine.Scripting.Preserve] public string serverName;
            [UnityEngine.Scripting.Preserve] public string serverIcon;
            [UnityEngine.Scripting.Preserve] public string catalog;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class AttestRequest
        {
            [UnityEngine.Scripting.Preserve] public string token;
            [UnityEngine.Scripting.Preserve] public string attestation;
            [UnityEngine.Scripting.Preserve] public string error;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class AttestResponse
        {
            [UnityEngine.Scripting.Preserve] public bool ok;
            [UnityEngine.Scripting.Preserve] public string kick;
            [UnityEngine.Scripting.Preserve] public string attestNonce;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class PlayBeatRequest
        {
            [UnityEngine.Scripting.Preserve] public string token;
            [UnityEngine.Scripting.Preserve] public bool ending;
            [UnityEngine.Scripting.Preserve] public long announceAfter = -1;
            [UnityEngine.Scripting.Preserve] public string room = "";
            [UnityEngine.Scripting.Preserve] public string acProof = "";
            [UnityEngine.Scripting.Preserve] public int acScans;
            [UnityEngine.Scripting.Preserve] public string acFlags = "";
            [UnityEngine.Scripting.Preserve] public string codeHashes = "";
            [UnityEngine.Scripting.Preserve] public string codeAnswer = "";
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class LookupRequest
        {
            [UnityEngine.Scripting.Preserve] public string token;
            [UnityEngine.Scripting.Preserve] public string query;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class BeatResponse
        {
            [UnityEngine.Scripting.Preserve] public bool ok;
            [UnityEngine.Scripting.Preserve] public string acNonce;
            [UnityEngine.Scripting.Preserve] public string attestNonce;
            [UnityEngine.Scripting.Preserve] public string codeChallenge;
            [UnityEngine.Scripting.Preserve] public string kick;
            [UnityEngine.Scripting.Preserve] public long muteUntil;
            [UnityEngine.Scripting.Preserve] public bool muteCleared;
            [UnityEngine.Scripting.Preserve] public long shadowMuteUntil;
            [UnityEngine.Scripting.Preserve] public bool shadowMuteCleared;
            [UnityEngine.Scripting.Preserve] public bool itemsChanged;
            [UnityEngine.Scripting.Preserve] public long announceId;
            [UnityEngine.Scripting.Preserve] public string announcement;
            [UnityEngine.Scripting.Preserve] public EzAdminAnnouncement announceStyle;
            [UnityEngine.Scripting.Preserve] public bool frozen;
            [UnityEngine.Scripting.Preserve] public bool freezeChanged;
            [UnityEngine.Scripting.Preserve] public long shutdownAt;
            [UnityEngine.Scripting.Preserve] public string shutdownMessage;
            [UnityEngine.Scripting.Preserve] public string eventName;
            [UnityEngine.Scripting.Preserve] public float eventMultiplier;
            [UnityEngine.Scripting.Preserve] public long eventEndsAt;
            [UnityEngine.Scripting.Preserve] public bool staff;
            [UnityEngine.Scripting.Preserve] public bool staffTools;
            [UnityEngine.Scripting.Preserve] public long now;
            [UnityEngine.Scripting.Preserve] public long liveopsRev;
            [UnityEngine.Scripting.Preserve] public bool runCloudScript;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class ItemsRequest
        {
            [UnityEngine.Scripting.Preserve] public string token;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        public class OwnedItem
        {

            [UnityEngine.Scripting.Preserve] public string id;

            [UnityEngine.Scripting.Preserve] public string name;
            [UnityEngine.Scripting.Preserve] public int amount;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class ItemsResponse
        {
            [UnityEngine.Scripting.Preserve] public bool ok;
            [UnityEngine.Scripting.Preserve] public string message;
            [UnityEngine.Scripting.Preserve] public OwnedItem[] items;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class ProfileRequest
        {
            [UnityEngine.Scripting.Preserve] public string token;
            [UnityEngine.Scripting.Preserve] public string targetPlayFabId;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class PageRequest
        {
            [UnityEngine.Scripting.Preserve] public string token;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class PageResponse
        {
            [UnityEngine.Scripting.Preserve] public bool ok;
            [UnityEngine.Scripting.Preserve] public string message;
            [UnityEngine.Scripting.Preserve] public string url;
            [UnityEngine.Scripting.Preserve] public string code;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class ProfilesRequest
        {
            [UnityEngine.Scripting.Preserve] public string token;
            [UnityEngine.Scripting.Preserve] public string[] playFabIds;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class ProfilesEntry
        {
            [UnityEngine.Scripting.Preserve] public string playFabId;
            [UnityEngine.Scripting.Preserve] public bool linked;
            [UnityEngine.Scripting.Preserve] public bool visible;
            [UnityEngine.Scripting.Preserve] public long discordId;
            [UnityEngine.Scripting.Preserve] public string name;
            [UnityEngine.Scripting.Preserve] public string username;
            [UnityEngine.Scripting.Preserve] public string avatarUrl;
            [UnityEngine.Scripting.Preserve] public string profileUrl;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class ProfilesResponse
        {
            [UnityEngine.Scripting.Preserve] public bool ok;
            [UnityEngine.Scripting.Preserve] public ProfilesEntry[] profiles;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class ProfileResponse
        {
            [UnityEngine.Scripting.Preserve] public bool ok;
            [UnityEngine.Scripting.Preserve] public bool linked;
            [UnityEngine.Scripting.Preserve] public bool visible;

            [UnityEngine.Scripting.Preserve] public long discordId;
            [UnityEngine.Scripting.Preserve] public string name;
            [UnityEngine.Scripting.Preserve] public string avatarUrl;
            [UnityEngine.Scripting.Preserve] public string profileUrl;
        }
    }

    public sealed class EzAdminValues
    {
        private readonly Dictionary<string, object> _values;

        private EzAdminValues(Dictionary<string, object> values)
        {
            _values = values ?? new Dictionary<string, object>();
        }

        public static readonly EzAdminValues Empty = new EzAdminValues(null);

        public IEnumerable<string> Keys => _values.Keys;

        public bool Has(string key) => key != null && _values.ContainsKey(key);

        public double GetNumber(string key, double fallback = 0)
        {
            object value;
            return key != null && _values.TryGetValue(key, out value) && value is double ? (double)value : fallback;
        }

        public int GetInt(string key, int fallback = 0)
        {
            object value;
            return key != null && _values.TryGetValue(key, out value) && value is double
                ? (int)Math.Round((double)value) : fallback;
        }

        public bool GetFlag(string key, bool fallback = false)
        {
            object value;
            return key != null && _values.TryGetValue(key, out value) && value is bool ? (bool)value : fallback;
        }

        public string GetText(string key, string fallback = "")
        {
            object value;
            return key != null && _values.TryGetValue(key, out value) && value is string ? (string)value : fallback;
        }

        public static EzAdminValues Parse(string json)
        {
            var values = new Dictionary<string, object>();
            if (string.IsNullOrEmpty(json))
            {
                return new EzAdminValues(values);
            }
            try
            {
                int i = 0;
                SkipSpace(json, ref i);
                if (i >= json.Length || json[i] != '{')
                {
                    return Empty;
                }
                i++;
                while (true)
                {
                    SkipSpace(json, ref i);
                    if (i >= json.Length || json[i] == '}')
                    {
                        break;
                    }
                    string key = ReadString(json, ref i);
                    SkipSpace(json, ref i);
                    if (key == null || i >= json.Length || json[i] != ':')
                    {
                        break;
                    }
                    i++;
                    SkipSpace(json, ref i);
                    object value = ReadValue(json, ref i);
                    if (value != null)
                    {
                        values[key] = value;
                    }
                    SkipSpace(json, ref i);
                    if (i < json.Length && json[i] == ',')
                    {
                        i++;
                    }
                }
            }
            catch (Exception)
            {

            }
            return new EzAdminValues(values);
        }

        private static void SkipSpace(string s, ref int i)
        {
            while (i < s.Length && char.IsWhiteSpace(s[i]))
            {
                i++;
            }
        }

        private static string ReadString(string s, ref int i)
        {
            if (i >= s.Length || s[i] != '"')
            {
                return null;
            }
            i++;
            var text = new StringBuilder();
            while (i < s.Length && s[i] != '"')
            {
                if (s[i] == '\\' && i + 1 < s.Length)
                {
                    i++;
                    char escaped = s[i];
                    if (escaped == 'u' && i + 4 < s.Length)
                    {
                        text.Append((char)Convert.ToInt32(s.Substring(i + 1, 4), 16));
                        i += 4;
                    }
                    else
                    {
                        text.Append(escaped == 'n' ? '\n' : escaped == 't' ? '\t' : escaped);
                    }
                }
                else
                {
                    text.Append(s[i]);
                }
                i++;
            }
            i++;
            return text.ToString();
        }

        private static object ReadValue(string s, ref int i)
        {
            if (s[i] == '"')
            {
                return ReadString(s, ref i);
            }
            if (string.CompareOrdinal(s, i, "true", 0, 4) == 0)
            {
                i += 4;
                return true;
            }
            if (string.CompareOrdinal(s, i, "false", 0, 5) == 0)
            {
                i += 5;
                return false;
            }
            int start = i;
            while (i < s.Length && "+-0123456789.eE".IndexOf(s[i]) >= 0)
            {
                i++;
            }
            double number;
            if (i > start && double.TryParse(s.Substring(start, i - start), NumberStyles.Float,
                    CultureInfo.InvariantCulture, out number))
            {
                return number;
            }

            while (i < s.Length && s[i] != ',' && s[i] != '}')
            {
                i++;
            }
            return null;
        }
    }

    public readonly struct EzAdminProfile
    {

        public bool Unreachable { get; }

        public bool Linked { get; }

        public bool Visible { get; }
        public long DiscordId { get; }
        public string Name { get; }
        public string AvatarUrl { get; }

        public string ProfileUrl { get; }

        public string Username { get; }

        public string DiscordIdText => DiscordId == 0 ? string.Empty : DiscordId.ToString();

        public EzAdminProfile(bool linked, bool visible, long discordId, string name, string avatarUrl, string profileUrl)
            : this(linked, visible, discordId, name, avatarUrl, profileUrl, string.Empty)
        {
        }

        public EzAdminProfile(bool linked, bool visible, long discordId, string name, string avatarUrl,
                              string profileUrl, string username)
        {
            Username = username ?? string.Empty;
            Unreachable = false;
            Linked = linked;
            Visible = visible;
            DiscordId = discordId;
            Name = name ?? string.Empty;
            AvatarUrl = avatarUrl ?? string.Empty;
            ProfileUrl = profileUrl ?? string.Empty;
        }

        private EzAdminProfile(bool unreachable)
        {
            Username = string.Empty;
            Unreachable = unreachable;
            Linked = false;
            Visible = false;
            DiscordId = 0;
            Name = string.Empty;
            AvatarUrl = string.Empty;
            ProfileUrl = string.Empty;
        }

        public static readonly EzAdminProfile Unavailable = new EzAdminProfile(true);
    }

}
