﻿using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using Oxide.Core;
using Oxide.Core.Libraries.Covalence;
using Oxide.Core.Plugins;
using System.Text;
using Oxide.Plugins.PayNowExtensions;
using UnityEngine;
using UnityEngine.Networking;
#if !RUST
using Oxide.Core.Unity;
#endif

namespace Oxide.Plugins
{
    [Info("PayNow", "PayNow Services Inc", "0.0.18")]
    [Description("Official plugin for the PayNow.gg store integration.")]
    internal class PayNow : CovalencePlugin
    {
        const string COMMAND_QUEUE_URL = "https://api.paynow.gg/v1/delivery/command-queue/";
        const string GS_LINK_URL = "https://api.paynow.gg/v1/delivery/gameserver/link";
        const string EVENTS_URL = "https://api.paynow.gg/v1/delivery/events";

        PluginConfig _config;

        readonly Dictionary<string, string> _headers = new Dictionary<string, string>();
        readonly CommandHistory _executedCommands = new CommandHistory(25);
        readonly StringBuilder _cachedStringBuilder = new StringBuilder();
        readonly HashSet<string> _commandsToAcknowledge = new HashSet<string>();
        readonly HashSet<DeliveryEvent> _pendingEvents = new HashSet<DeliveryEvent>();
        private Timer _pendingCommandsTimer;
        private Timer _eventsTimer;
        
        #region Oxide

        [HookMethod("Loaded")]
        void Loaded()
        {
            if (string.IsNullOrEmpty(_config.ApiToken))
                PrintWarning("No API token set! Use the 'paynow.token <token>' command to set it.");

            UpdateHeaders();
        }

        [HookMethod("OnServerInitialized")]
        void OnServerInitialized() => ValidateToken();

        [HookMethod("OnUserConnected")]
        void OnUserConnected(IPlayer player)
        {
            _pendingEvents.Add(new DeliveryEvent
            {
                IpAddress = player.Address,
                SteamId = player.Id,
                Timestamp = DateTime.UtcNow.ToString("o")
            });
        }
        
        [Command("paynow.token")]
        void CommandToken(IPlayer player, string command, string[] args)
        {
            if (!player.IsServer && !player.IsAdmin)
                return;

            if (args.Length != 1)
            {
                player.Reply("Usage: paynow.token <token>");
                return;
            }

            StopTimers();
            _config.ApiToken = args[0]?.Trim();
            SaveConfig();
            UpdateHeaders();

            player.Reply("Successfully saved the PayNow API token! Attempting to validate it...");
            ValidateToken();
        }

        void ValidateToken() => LinkGameServer(StartTimers);

        void StartTimers()
        {
            StartPendingCommandsLoop();
            StartEventsLoop();
        }
        
        void StartPendingCommandsLoop()
        {
            if (_pendingCommandsTimer != null) return;
            Puts("Started checking for pending commands");
            GetPendingCommands();
            _pendingCommandsTimer = timer.Every(_config.ApiCheckIntervalSeconds, GetPendingCommands);
        }
        
        void StartEventsLoop()
        {
            if (_eventsTimer != null) return;
            SendPendingEvents();
            _eventsTimer = timer.Every(60, SendPendingEvents);
        }

        void StopTimers()
        {
            timer.Destroy(ref _pendingCommandsTimer);
            timer.Destroy(ref _eventsTimer);
        }

        #endregion

        #region WebRequests

        void LinkGameServer(Action continuationCallback)
        {
            // Don't make the API call if we don't have a token
            if (string.IsNullOrEmpty(_config.ApiToken))
                return;

            var data = new LinkGameServerRequest
            {
                Ip = server.Address + ":" + server.Port,
                Hostname = server.Name,
                Platform = game,
                Version = Version.ToString()
            };

            try
            {
                // Make the API call
                SendWebRequest(HandlePostRequest(GS_LINK_URL, JsonConvert.SerializeObject(data), (code, responseString) => HandleLinkGameServerResponse(code, responseString, continuationCallback), _headers));
            }
            catch (Exception ex)
            {
                PrintException("Failure initiate game server link request to PayNow, trying again in 5 seconds...", ex);
                timer.In(5, () => LinkGameServer(continuationCallback));
                return;
            }
        }

        void HandleLinkGameServerResponse(int code, string responseString, Action continuationCallback)
        {
            // Check we are authorised to be here...
            if (code == 401 || code == 403)
            {
                PrintError("Failure linking game server to PayNow, invalid token supplied. Please update your token and try again");
                return;
            }

            // Check if we got a valid response code....
            if (code >= 500)
            {
                PrintWarning("Failure linking game server to PayNow, trying again in 5 seconds...");
                timer.In(5, () => LinkGameServer(continuationCallback));
                return;
            }

            // Check if we got a valid response code at all...
            if(code != 200 || responseString == null)
            {
                PrintError($"Failure linking game server to PayNow, unexpected response code: ({code}) ({responseString}), trying again in 5 seconds...");
                timer.In(5, () => LinkGameServer(continuationCallback));
                return;
            }

            LinkGameServerResponse response;
            try
            {
                response = JsonConvert.DeserializeObject<LinkGameServerResponse>(responseString);
            }
            catch (Exception ex)
            {
                PrintException("Failure whilst deserializing link game server response, trying again in 5 seconds...", ex);
                timer.In(5, () => LinkGameServer(continuationCallback));
                return;
            }

            if (response == null)
            {
                PrintError("PayNow API returned a null link game server response, trying again in 5 seconds...");
                timer.In(5, () => LinkGameServer(continuationCallback));
                return;
            }

            if (response.UpdateAvailable)
            {
                Puts("Update available! latest version: {0}, current version: {1}", response.LatestVersion, Version.ToString());
            }

            if (response.PreviouslyLinked != null)
            {
                PrintWarning("This token has been previously used on \"{0}\" ({1}), ensure you have removed this token from the previous server.", response.PreviouslyLinked.HostName, response.PreviouslyLinked.IP);
            }

            if (response.GameServer == null)
            {
                PrintError("PayNow API did not return a GameServer object, this may be a transient issue, please try again or contact support.");
                return;
            }

            Puts("Connected to PayNow using the token for \"{0}\" ({1}) successfully!", response.GameServer.Name, response.GameServer.Id);
            continuationCallback?.Invoke();
        }

        void GetPendingCommands()
        {
            // Don't make the API call if we don't have a token
            if (string.IsNullOrEmpty(_config.ApiToken))
                return;

            try
            {
                // Make the API call
                SendWebRequest(HandlePostRequest(COMMAND_QUEUE_URL, BuildOnlinePlayersJson(), HandlePendingCommands, _headers));
            }
            catch (Exception ex)
            {
                PrintException("Failed retrieve get pending commands", ex);
            }
        }

        void HandlePendingCommands(int code, string response)
        {
            try
            {
                // Check if we got a valid response
                if (code != 200 || response == null)
                    throw new Exception($"Server sent an invalid response: ({code}) ({response})");

                // Deserialize the response
                QueuedCommand[] data = JsonConvert.DeserializeObject<QueuedCommand[]>(response);
                if (data == null)
                    throw new Exception($"Response deserialized to null: ({response})");

                // Process the data
                ProcessPendingCommands(data);
            }
            catch (Exception ex)
            {
                PrintException("Failed handle pending commands", ex);
            }
        }

        void AcknowledgeCommands(HashSet<string> commandsIds)
        {
            // Check if we have any order ids to acknowledge
            if (commandsIds.Count == 0) return;

            try
            {
                // Make the API call to acknowledge the commands
                SendWebRequest(HandlePostRequest(COMMAND_QUEUE_URL, BuildAcknowledgeJson(commandsIds), HandleAcknowledgeCommands, _headers, "DELETE"));
            }
            catch (Exception ex)
            {
                PrintException("Failed to acknowledge commands", ex);
            }
        }

        void HandleAcknowledgeCommands(int code, string response)
        {
            // Check if we got a valid response
            if (code >= 200 && code < 300) return;

            // Log an error if we didn't get a 204 response
            PrintError($"Command acknowledgement resulted in an unexpected response code: ({code}) ({response})");
        }

        void SendPendingEvents()
        {
            // Don't make the API call if we don't have a token
            if (string.IsNullOrEmpty(_config.ApiToken))
                return;
            
            // Check if we have any pending events
            if (_pendingEvents.Count == 0)
                return;

            try
            {
                // Make the API call to send the events
                SendWebRequest(HandlePostRequest(EVENTS_URL, BuildPendingEventsJson(), HandleSendPendingEvents, _headers));
            }
            catch (Exception ex)
            {
                PrintException("Failed to send pending events", ex);
            }
        }
        
        void HandleSendPendingEvents(int code, string response)
        {
            // Check if we got a valid response
            if (code >= 200 && code < 300)
            {
                _pendingEvents.Clear();
                return;
            }

            // Log an error if we didn't get a 204 response
            PrintError($"Sending pending events resulted in an unexpected response code: ({code}) ({response})");
        }
        
        #endregion

        #region Command Processing

        void ProcessPendingCommands(QueuedCommand[] queuedCommands)
        {
            // Check if we got any data
            if (queuedCommands.Length == 0)
                return;

            int executedCommands = 0;
            _commandsToAcknowledge.Clear();
            for (int i = 0; i < queuedCommands.Length; i++)
            {
                QueuedCommand command = queuedCommands[i];

                // Make sure we don't execute the same command twice
                if (_executedCommands.Contains(command.AttemptId))
                {
                    _commandsToAcknowledge.Add(command.AttemptId);
                    continue;
                }

                try
                {
                    if (command.OnlineOnly && players.Connected.All(x => x.Id != command.SteamId))
                        continue;

                    // Try executing the command
                    if (ExecuteCommand(command.Command))
                    {
                        // Add the order id to the list of acknowledged orders
                        executedCommands += 1;
                        _commandsToAcknowledge.Add(command.AttemptId);
                        _executedCommands.Add(command.AttemptId);
                    }
                    else
                    {
                        // Log an error if the command failed
                        PrintWarning($"Failed to run command {command.Command} ({command.AttemptId})!");
                    }
                }
                catch (Exception ex)
                {
                    // Log an error if an exception occurs
                    PrintException("Failed to execute command", ex);
                }
            }

            // Log the amount of commands we executed
            if (_config.LogCommandExecutions)
                Puts(
                    $"Received {queuedCommands.Length.ToString()} and executed {executedCommands.ToString()} commands!");

            // Acknowledge the commands
            AcknowledgeCommands(_commandsToAcknowledge);
        }

        bool ExecuteCommand(string command)
        {
            // Run the command
            server.Command(command);

            // TODO: Fetch Command Response, currently not possible when using oxide covalence libraries

            return true;
        }

        #endregion

        #region Api DTOs

        [Serializable]
        public class QueuedCommand
        {
            [JsonProperty("attempt_id")] public string AttemptId;

            [JsonProperty("steam_id")] public string SteamId;

            [JsonProperty("command")] public string Command;

            [JsonProperty("online_only")] public bool OnlineOnly;

            [JsonProperty("queued_at")] public string QueuedAt;
        }

        [Serializable]
        public class LinkGameServerRequest
        {
            [JsonProperty("ip")] public string Ip;

            [JsonProperty("hostname")] public string Hostname;

            [JsonProperty("platform")] public string Platform;

            [JsonProperty("version")] public string Version;
        }

        [Serializable]
        public class LinkGameServerResponse
        {
            [JsonProperty("update_available")] public bool UpdateAvailable { get; set; }

            [JsonProperty("latest_version")] public string LatestVersion { get; set; }

            [JsonProperty("previously_linked")] public PreviouslyLinkedData PreviouslyLinked { get; set; }

            [JsonProperty("gameserver")] public GameServerData GameServer { get; set; }

            public class PreviouslyLinkedData
            {
                [JsonProperty("ip")] public string IP { get; set; }
                [JsonProperty("host_name")] public string HostName { get; set; }
                [JsonProperty("last_linked_at")] public DateTime LastLinkedAt { get; set; }
            }

            public class GameServerData
            {
                [JsonProperty("id")] public long Id { get; set; }
                [JsonProperty("store_id")] public long StoreId { get; set; }
                [JsonProperty("name")] public string Name { get; set; }
                [JsonProperty("created_at")] public DateTime? CreatedAt { get; set; }
                [JsonProperty("updated_at")] public DateTime? UpdatedAt { get; set; }
            }
        }

        #endregion
        
        #region Delivery Event

        private string BuildPendingEventsJson()
        {
            _cachedStringBuilder.Clear();
            _cachedStringBuilder.Append("[");
            
            if (_pendingEvents.Count > 0)
            {
                foreach (var element in _pendingEvents)
                {
                    _cachedStringBuilder.Append("{\"event\":\"player_join\",\"player_join\":{");
                    _cachedStringBuilder.AppendFormat("\"ip_address\":\"{0}\",", element.IpAddress);
                    _cachedStringBuilder.AppendFormat("\"steam_id\":\"{0}\"", element.SteamId);
                    _cachedStringBuilder.Append("},");
                    _cachedStringBuilder.AppendFormat("\"timestamp\":\"{0}\"", element.Timestamp);
                    _cachedStringBuilder.Append("},");
                }

                _cachedStringBuilder.Remove(_cachedStringBuilder.Length - 1, 1);
            }
            
            _cachedStringBuilder.Append("]");
            return _cachedStringBuilder.ToString();
        }

        private struct DeliveryEvent
        {
            public string IpAddress;
            public string SteamId;
            public string Timestamp;
        }
        
        #endregion

        #region Configuration

        [Serializable]
        class PluginConfig
        {
            [JsonProperty("API token")] public string ApiToken = string.Empty;

            [JsonProperty("Time between API checks in seconds")]
            public float ApiCheckIntervalSeconds = 10;

            [JsonProperty("Log command executions")]
            public bool LogCommandExecutions = true;

            // Backwards compatibility
            [JsonProperty("ApiToken")]
            public string OldApiToken
            {
                set { ApiToken = value; }
            }

            [JsonProperty("ApiCheckIntervalSeconds")]
            public float OldApiCheckIntervalSeconds
            {
                set { ApiCheckIntervalSeconds = value; }
            }
        }

        protected override void LoadDefaultConfig()
        {
            _config = new PluginConfig();
        }

        protected override void LoadConfig()
        {
            base.LoadConfig();

            _config = Config.ReadObject<PluginConfig>();

            SaveConfig();
        }

        protected override void SaveConfig()
        {
            Config.WriteObject(_config, true);
        }

        #endregion

        #region Helpers

        void UpdateHeaders()
        {
            _headers["Content-Type"] = "application/json";
            _headers["Authorization"] = "Gameserver " + _config.ApiToken;
        }

        string BuildAcknowledgeJson(HashSet<string> orderIds)
        {
            _cachedStringBuilder.Clear();

            // Json format [{"attempt_id": "123"}]
            _cachedStringBuilder.Append("[");

            if (orderIds.Count > 0)
            {
                foreach (var element in orderIds)
                {
                    _cachedStringBuilder.Append("{\"attempt_id\": \"");
                    _cachedStringBuilder.Append(element);
                    _cachedStringBuilder.Append("\"}");
                    _cachedStringBuilder.Append(",");
                }

                _cachedStringBuilder.Remove(_cachedStringBuilder.Length - 1, 1);
            }

            _cachedStringBuilder.Append("]");

            return _cachedStringBuilder.ToString();
        }

        string BuildOnlinePlayersJson()
        {
            _cachedStringBuilder.Clear();

            // Json format {"steam_ids": ["123"]}
            _cachedStringBuilder.Append("{\"steam_ids\":[");
            var addedPlayers = false;
            foreach (var player in players.Connected)
            {
                addedPlayers = true;
                _cachedStringBuilder.Append("\"");
                _cachedStringBuilder.Append(player.Id);
                _cachedStringBuilder.Append("\"");
                _cachedStringBuilder.Append(",");
            }

            if (addedPlayers) _cachedStringBuilder.Remove(_cachedStringBuilder.Length - 1, 1);

            _cachedStringBuilder.Append("]}");

            return _cachedStringBuilder.ToString();
        }

        class CommandHistory
        {
            readonly Queue<string> _queue;
            readonly int _capacity;

            public CommandHistory(int capacity)
            {
                _capacity = capacity;
                _queue = new Queue<string>(capacity);
            }

            public void Add(string command)
            {
                if (_queue.Count >= _capacity)
                    _queue.Dequeue();

                _queue.Enqueue(command);
            }

            public bool Contains(string command) => _queue.Contains(command);
        }

        void PrintException(string message, Exception ex) => Interface.Oxide.LogException($"[{Title}] {message}", ex);

        #endregion
        
        #region Unity WebRequest
        
#if RUST
        private MonoBehaviour _monoBehaviour = Rust.Global.Runner;
#else
        private MonoBehaviour _monoBehaviour = UnityScript.Instance.GetComponent<MonoBehaviour>();
#endif

        private void SendWebRequest(IEnumerator webRequestCoroutine)
        {
            if( _monoBehaviour == null )
            {
                throw new Exception("_monoBehaviour is null, cannot send web request!");
            }
            
            _monoBehaviour.StartCoroutine(webRequestCoroutine);
        }
        
        private static IEnumerator HandlePostRequest(string url, string jsonData, Action<int, string> callback, Dictionary<string, string> headers = null, string method = "POST")
        {
            // Convert the JSON data to a byte array
            byte[] postData = Encoding.UTF8.GetBytes(jsonData);

            // Use UnityWebRequest to make a POST request
            using (UnityWebRequest webRequest = new UnityWebRequest(url, method))
            {
                // Set the request body and headers
                webRequest.uploadHandler = new UploadHandlerRaw(postData);
                webRequest.downloadHandler = new DownloadHandlerBuffer();
                webRequest.timeout = 30;

                if (headers != null)
                {
                    foreach (var header in headers)
                    {
                        webRequest.SetRequestHeader(header.Key, header.Value);
                    }
                }
                
                // Send the request and wait for a response
                yield return webRequest.SendWebRequest();
                
                // Check for errors
                if(webRequest.IsError())
                {
                    // Log the error and invoke the callback with the error code
                    callback?.Invoke((int) webRequest.responseCode, webRequest.error);
                }
                else
                {
                    // Invoke the callback with the response data
                    callback?.Invoke((int) webRequest.responseCode, webRequest.downloadHandler.text);
                }
            }
        }
        
        #endregion
    }
}

namespace Oxide.Plugins.PayNowExtensions
{
    internal static class Extensions
    {
        public static bool IsError(this UnityWebRequest webRequest)
        {
#if HURTWORLD
            return webRequest.isError;
#else
            return webRequest.result == UnityWebRequest.Result.ConnectionError || webRequest.result == UnityWebRequest.Result.ProtocolError;
#endif
        }

#if HURTWORLD
        public static AsyncOperation SendWebRequest(this UnityWebRequest webRequest)
        {
            return webRequest.Send();
        }

        public static void Clear(this StringBuilder stringBuilder)
        {
            stringBuilder.Length = 0;
            stringBuilder.Capacity = 0;
        }
#endif
    }
}
