using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using UnityEngine;
using UnityEngine.Networking;

namespace Getly.Publisher
{
    /// <summary>
    /// Every request the tool makes, and nothing else.
    ///
    /// UnityWebRequest rather than HttpClient: the editor's synchronisation
    /// context is not a place to be blocking on tasks, and UnityWebRequest's
    /// progress reporting is what makes a 300 MB upload survivable for the
    /// person watching it.
    ///
    /// Callbacks rather than async/await, because this has to compile on the
    /// 2019 LTS a lot of asset publishers are still shipping from.
    /// </summary>
    public static class GetlyApi
    {
        public const string ClientName = "getly-publisher-unity/0.1.0";

        // ---- device sign-in ---------------------------------------------------

        [Serializable]
        public class DeviceStart
        {
            public string deviceCode;
            public string userCode;
            public string verificationUrl;
            public int expiresIn;
            public int interval;
        }

        [Serializable]
        public class DevicePoll
        {
            public string status;   // pending | approved | denied | expired | unknown
            public string apiKey;
            public string storeId;
            public string storeName;
            public int interval;
        }

        public static void StartDeviceAuth(Action<DeviceStart, string> done)
        {
            var body = "{\"client\":\"" + Escape(ClientName) + "\",\"scopes\":[\"read:products\",\"write:products\",\"read:store\"]}";
            PostJson("/api/publisher/device/start", body, false, (json, error) =>
            {
                if (error != null) { done(null, error); return; }
                done(ParseData<DeviceStart>(json), null);
            });
        }

        public static void PollDeviceAuth(string deviceCode, Action<DevicePoll, string> done)
        {
            var body = "{\"deviceCode\":\"" + Escape(deviceCode) + "\"}";
            PostJson("/api/publisher/device/poll", body, false, (json, error) =>
            {
                if (error != null) { done(null, error); return; }
                done(ParseData<DevicePoll>(json), null);
            });
        }

        // ---- product ----------------------------------------------------------

        [Serializable]
        public class CreatedProduct
        {
            public string id;
            public string slug;
        }

        public static void CreateDraft(GetlySubmission submission, Action<CreatedProduct, string> done)
        {
            var body = new StringBuilder();
            body.Append("{\"name\":\"").Append(Escape(submission.Name)).Append('"');
            body.Append(",\"description\":\"").Append(Escape(submission.Description)).Append('"');
            body.Append(",\"price\":").Append(submission.PriceCents);
            body.Append(",\"status\":\"draft\"");
            if (!string.IsNullOrEmpty(submission.CategoryId))
                body.Append(",\"categoryId\":\"").Append(Escape(submission.CategoryId)).Append('"');
            if (!string.IsNullOrEmpty(submission.LicenseType))
                body.Append(",\"licenseType\":\"").Append(Escape(submission.LicenseType)).Append('"');
            body.Append('}');

            PostJson("/api/v1/products", body.ToString(), true, (json, error) =>
            {
                if (error != null) { done(null, error); return; }
                done(ParseData<CreatedProduct>(json), null);
            });
        }

        // ---- upload -----------------------------------------------------------

        [Serializable]
        public class PresignResult
        {
            public string uploadUrl;
            public string fileUrl;
        }

        public static void PresignUpload(string productId, string fileName, long fileSize, Action<PresignResult, string> done)
        {
            var body = "{\"fileName\":\"" + Escape(fileName) + "\",\"fileSize\":" + fileSize + "}";
            PostJson("/api/v1/products/" + productId + "/files/presign", body, true, (json, error) =>
            {
                if (error != null) { done(null, error); return; }
                done(ParseData<PresignResult>(json), null);
            });
        }

        /// <summary>
        /// PUT the bytes straight to storage.
        ///
        /// Reported through <paramref name="onProgress"/> because a publisher's
        /// package is routinely hundreds of megabytes, and a window that looks
        /// frozen for four minutes gets force-quit halfway through.
        /// </summary>
        public static UnityWebRequest UploadFile(string uploadUrl, byte[] bytes, Action<float> onProgress, Action<string> done)
        {
            var request = UnityWebRequest.Put(uploadUrl, bytes);
            request.SetRequestHeader("Content-Type", "application/octet-stream");
            var operation = request.SendWebRequest();

            operation.completed += _ =>
            {
                var failed = IsError(request);
                done(failed ? Describe(request) : null);
                request.Dispose();
            };

            GetlyEditorLoop.Run(() =>
            {
                if (request.isDone) return false;
                onProgress?.Invoke(request.uploadProgress);
                return true;
            });

            return request;
        }

        public static void AttachFile(string productId, string fileUrl, string fileName, long fileSize, Action<string> done)
        {
            var body = "{\"fileUrl\":\"" + Escape(fileUrl) + "\",\"fileName\":\"" + Escape(fileName)
                       + "\",\"fileSize\":" + fileSize + ",\"fileType\":\"application/octet-stream\"}";
            PostJson("/api/v1/products/" + productId + "/files", body, true, (_, error) => done(error));
        }

        // ---- verification -----------------------------------------------------

        [Serializable]
        public class VerifyFinding
        {
            public string id;
            public string path;
            public string detail;
            public string severity;
            public string title;
            public string why;
            public string fix;
        }

        [Serializable]
        public class VerifyResult
        {
            public bool verified;
            public bool canPublish;
            public string reason;
            public string message;
            public int filesScanned;
            public VerifyFinding[] findings;
        }

        public static void Verify(string productId, IReadOnlyList<GetlyFinding> localFindings, Action<VerifyResult, string> done)
        {
            var body = new StringBuilder();
            body.Append("{\"productId\":\"").Append(Escape(productId)).Append('"');
            body.Append(",\"clientReport\":{\"version\":").Append(GetlyChecks.Version);
            body.Append(",\"tool\":\"").Append(GetlyChecks.Tool).Append('"');
            body.Append(",\"client\":\"").Append(Escape(ClientName)).Append('"');
            body.Append(",\"hostVersion\":\"").Append(Escape(Application.unityVersion)).Append('"');
            body.Append(",\"findings\":[");
            for (var i = 0; i < localFindings.Count; i++)
            {
                if (i > 0) body.Append(',');
                body.Append("{\"id\":\"").Append(Escape(localFindings[i].Id)).Append('"');
                if (!string.IsNullOrEmpty(localFindings[i].Path))
                    body.Append(",\"path\":\"").Append(Escape(localFindings[i].Path)).Append('"');
                body.Append('}');
            }
            body.Append("]}}");

            PostJson("/api/publisher/unity/verify", body.ToString(), true, (json, error) =>
            {
                if (error != null) { done(null, error); return; }
                done(ParseData<VerifyResult>(json), null);
            });
        }

        // ---- plumbing ---------------------------------------------------------

        private static void PostJson(string path, string body, bool authenticated, Action<string, string> done)
        {
            var url = GetlySession.BaseUrl.TrimEnd('/') + path;
            var request = new UnityWebRequest(url, "POST")
            {
                uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(body)),
                downloadHandler = new DownloadHandlerBuffer(),
            };
            request.SetRequestHeader("Content-Type", "application/json");
            if (authenticated)
            {
                if (!GetlySession.IsSignedIn)
                {
                    done(null, "Not signed in.");
                    request.Dispose();
                    return;
                }
                request.SetRequestHeader("Authorization", "Bearer " + GetlySession.ApiKey);
            }

            request.SendWebRequest().completed += _ =>
            {
                var text = request.downloadHandler?.text;
                var failed = IsError(request);
                // The server states its own errors better than a status code
                // does; prefer its message whenever it sent one.
                var serverMessage = ExtractError(text);
                done(failed ? null : text, failed ? (serverMessage ?? Describe(request)) : null);
                request.Dispose();
            };
        }

        private static bool IsError(UnityWebRequest request)
        {
#if UNITY_2020_2_OR_NEWER
            return request.result != UnityWebRequest.Result.Success;
#else
            return request.isNetworkError || request.isHttpError;
#endif
        }

        /// <summary>
        /// Say what happened, in words.
        ///
        /// UnityWebRequest.error is a raw status line — "HTTP/1.1 404 Not
        /// Found" tells a publisher nothing about what to do next, and that is
        /// exactly what they screenshot when they report a problem.
        /// </summary>
        private static string Describe(UnityWebRequest request)
        {
            switch (request.responseCode)
            {
                case 0:
                    return "Could not reach " + GetlySession.BaseUrl + ". Check your connection.";
                case 400:
                    return "Getly refused the request. Update this tool, or contact support@getly.store.";
                case 401:
                    return "Your Getly sign-in has expired. Sign in again.";
                case 403:
                    return "This editor is not allowed to do that. Sign out and sign in again.";
                case 404:
                    // The whole-server case, not a missing product: this endpoint
                    // is part of the tool's own API, so a 404 means the address
                    // is wrong or that Getly does not have it yet.
                    return GetlySession.BaseUrl == GetlySession.DefaultBaseUrl
                        ? "Getly does not have the publishing API at this address. Update this tool, or contact support@getly.store."
                        : "No publishing API at " + GetlySession.BaseUrl + ". Check the server address under Advanced.";
                case 413:
                    return "That package is too large to upload.";
                case 429:
                    return "Getly is rate-limiting this tool. Wait a minute and retry.";
            }
            if (request.responseCode >= 500) return "Getly had a problem. Try again in a moment.";
            return string.IsNullOrEmpty(request.error) ? "Request failed." : request.error;
        }

        /// <summary>Pull `error` out of the platform's {success,error} envelope.</summary>
        private static string ExtractError(string json)
        {
            if (string.IsNullOrEmpty(json)) return null;
            const string marker = "\"error\":\"";
            var at = json.IndexOf(marker, StringComparison.Ordinal);
            if (at < 0) return null;
            var start = at + marker.Length;
            var end = json.IndexOf('"', start);
            while (end > start && json[end - 1] == '\\') end = json.IndexOf('"', end + 1);
            return end <= start ? null : json.Substring(start, end - start).Replace("\\\"", "\"").Replace("\\n", "\n");
        }

        /// <summary>
        /// JsonUtility cannot read `{success, data:{...}}` into the inner type,
        /// so lift `data` out first. Crude, and correct for the shapes we ask
        /// for — all of which are flat objects we define on both sides.
        /// </summary>
        private static T ParseData<T>(string json) where T : class
        {
            if (string.IsNullOrEmpty(json)) return null;
            const string marker = "\"data\":";
            var at = json.IndexOf(marker, StringComparison.Ordinal);
            if (at < 0) return JsonUtility.FromJson<T>(json);

            var start = json.IndexOf('{', at);
            if (start < 0) return null;

            var depth = 0;
            var inString = false;
            for (var i = start; i < json.Length; i++)
            {
                var c = json[i];
                if (inString)
                {
                    if (c == '\\') i++;
                    else if (c == '"') inString = false;
                    continue;
                }
                if (c == '"') inString = true;
                else if (c == '{') depth++;
                else if (c == '}')
                {
                    depth--;
                    if (depth == 0) return JsonUtility.FromJson<T>(json.Substring(start, i - start + 1));
                }
            }
            return null;
        }

        private static string Escape(string value)
        {
            if (string.IsNullOrEmpty(value)) return string.Empty;
            var sb = new StringBuilder(value.Length + 16);
            foreach (var c in value)
            {
                switch (c)
                {
                    case '"': sb.Append("\\\""); break;
                    case '\\': sb.Append("\\\\"); break;
                    case '\n': sb.Append("\\n"); break;
                    case '\r': sb.Append("\\r"); break;
                    case '\t': sb.Append("\\t"); break;
                    default:
                        if (c < 0x20) sb.Append("\\u").Append(((int)c).ToString("x4"));
                        else sb.Append(c);
                        break;
                }
            }
            return sb.ToString();
        }
    }

    /// <summary>What the seller filled in before submitting.</summary>
    public sealed class GetlySubmission
    {
        public string Name;
        public string Description;
        public int PriceCents;
        public string CategoryId;
        public string LicenseType;
        public string FolderPath;
    }

    /// <summary>
    /// A repeating callback on the editor's update loop.
    ///
    /// Needed because UnityWebRequest reports progress by polling, and the
    /// editor gives no other tick to poll it from.
    /// </summary>
    public static class GetlyEditorLoop
    {
        /// <summary>
        /// Do something on the next editor tick, once.
        ///
        /// The reason anything needs this: work started from inside OnGUI runs
        /// before the frame it just asked for can be painted, so a status
        /// message set immediately beforehand is never seen.
        /// </summary>
        public static void RunOnce(Action action)
        {
            void Handler()
            {
                UnityEditor.EditorApplication.update -= Handler;
                action();
            }
            UnityEditor.EditorApplication.update += Handler;
        }

        public static void Run(Func<bool> tick)
        {
            void Handler()
            {
                if (!tick()) UnityEditor.EditorApplication.update -= Handler;
            }
            UnityEditor.EditorApplication.update += Handler;
        }
    }
}
