using System.Text.RegularExpressions;

using App.Metrics;

using Humanizer;

using Myriad.Cache;
using Myriad.Extensions;
using Myriad.Rest;
using Myriad.Rest.Exceptions;
using Myriad.Rest.Types;
using Myriad.Rest.Types.Requests;
using Myriad.Types;

using Newtonsoft.Json;
using Newtonsoft.Json.Linq;

using Serilog;

using PluralKit.Core;

namespace PluralKit.Bot;

public class WebhookExecutionErrorOnDiscordsEnd: Exception { }

public class WebhookRateLimited: WebhookExecutionErrorOnDiscordsEnd
{
    // Exceptions for control flow? don't mind if I do
    // TODO: rewrite both of these as a normal exceptional return value (0?) in case of error to be discarded by caller
}

public record ProxyRequest
{
    public ulong GuildId { get; init; }
    public ulong ChannelId { get; init; }
    public ulong? ThreadId { get; init; }
    public string Name { get; init; }
    public string? AvatarUrl { get; init; }
    public string? Content { get; init; }
    public Message.Attachment[] Attachments { get; init; }
    public int FileSizeLimit { get; init; }
    public Embed[] Embeds { get; init; }
    public Sticker[] Stickers { get; init; }
    public bool AllowEveryone { get; init; }
}

public class WebhookExecutorService
{
    private readonly IDiscordCache _cache;
    private readonly HttpClient _client;
    private readonly ILogger _logger;
    private readonly IMetrics _metrics;
    private readonly DiscordApiClient _rest;
    private readonly WebhookCacheService _webhookCache;

    public WebhookExecutorService(IMetrics metrics, WebhookCacheService webhookCache, ILogger logger,
                                  HttpClient client, IDiscordCache cache, DiscordApiClient rest)
    {
        _metrics = metrics;
        _webhookCache = webhookCache;
        _client = client;
        _cache = cache;
        _rest = rest;
        _logger = logger.ForContext<WebhookExecutorService>();
    }

    public async Task<Message> ExecuteWebhook(ProxyRequest req)
    {
        _logger.Verbose("Invoking webhook in channel {Channel}", req.ChannelId);

        // Get a webhook, execute it
        var webhook = await _webhookCache.GetWebhook(req.ChannelId);
        var webhookMessage = await ExecuteWebhookInner(webhook, req);

        // Log the relevant metrics
        _metrics.Measure.Meter.Mark(BotMetrics.MessagesProxied);
        _logger.Information("Invoked webhook {Webhook} in channel {Channel} (thread {ThreadId})", webhook.Id,
            req.ChannelId, req.ThreadId);

        return webhookMessage;
    }

    public async Task<Message> EditWebhookMessage(ulong channelId, ulong messageId, string newContent)
    {
        var allowedMentions = newContent.ParseMentions() with
        {
            Roles = Array.Empty<ulong>(),
            Parse = Array.Empty<AllowedMentions.ParseType>()
        };

        ulong? threadId = null;
        var channel = await _cache.GetOrFetchChannel(_rest, channelId);
        if (channel.IsThread())
        {
            threadId = channelId;
            channelId = channel.ParentId.Value;
        }

        var webhook = await _webhookCache.GetWebhook(channelId);

        return await _rest.EditWebhookMessage(webhook.Id, webhook.Token, messageId,
            new WebhookMessageEditRequest { Content = newContent, AllowedMentions = allowedMentions },
            threadId);
    }

    private async Task<Message> ExecuteWebhookInner(Webhook webhook, ProxyRequest req, bool hasRetried = false)
    {
        var guild = await _cache.GetGuild(req.GuildId);
        var content = req.Content.Truncate(2000);

        var allowedMentions = content.ParseMentions();
        if (!req.AllowEveryone)
            allowedMentions = allowedMentions.RemoveUnmentionableRoles(guild) with
            {
                // also clear @everyones
                Parse = Array.Empty<AllowedMentions.ParseType>()
            };

        var webhookReq = new ExecuteWebhookRequest
        {
            Username = req.Name.FixProxyName().Truncate(80),
            Content = content,
            AllowedMentions = allowedMentions,
            AvatarUrl = !string.IsNullOrWhiteSpace(req.AvatarUrl) ? req.AvatarUrl : null,
            Embeds = req.Embeds,
            Stickers = req.Stickers,
        };

        MultipartFile[] files = null;
        var attachmentChunks = ChunkAttachmentsOrThrow(req.Attachments, req.FileSizeLimit);
        if (attachmentChunks.Count > 0)
        {
            _logger.Information(
                "Invoking webhook with {AttachmentCount} attachments totalling {AttachmentSize} MiB in {AttachmentChunks} chunks",
                req.Attachments.Length, req.Attachments.Select(a => a.Size).Sum() / 1024 / 1024,
                attachmentChunks.Count);
            files = await GetAttachmentFiles(attachmentChunks[0]);
            webhookReq.Attachments = files.Select(f => new Message.Attachment
            {
                Id = (ulong)Array.IndexOf(files, f),
                Description = f.Description,
                Filename = f.Filename
            }).ToArray();
        }

        Message webhookMessage;
        using (_metrics.Measure.Timer.Time(BotMetrics.WebhookResponseTime))
        {
            try
            {
                webhookMessage =
                    await _rest.ExecuteWebhook(webhook.Id, webhook.Token, webhookReq, files, req.ThreadId);
            }
            catch (BadRequestException e)
            {
                // explanation for hacky: I don't care if this code fails, it just means it wasn't a username error
                try
                {
                    var json = JsonConvert.DeserializeObject<JObject>(e.FormError);
                    var error = json.Value<JObject>("username").Value<JArray>("_errors").First.Value<string>("message");

                    await _rest.CreateMessage(req.ChannelId, new MessageRequest
                    {
                        Content = $"{Emojis.Error} Discord rejected your proxy name: {error.AsCode()}",
                        AllowedMentions = new AllowedMentions { Parse = { } },
                    });

                    Sentry.SentrySdk.CaptureException(e);

                    // this exception is ignored in the message handler lol
                    throw new ProxyService.ProxyChecksFailedException("_internal_discord_rejected_message");
                }
                catch (Exception ex)
                {
                    // this exception is expected, see comment above
                    if (ex.GetType() == typeof(ProxyService.ProxyChecksFailedException))
#pragma warning disable CA2200
                        throw ex;
#pragma warning restore CA2200
                    else
                        // if something breaks, just ignore it and throw the original exception
                        throw e;
                }
            }
            catch (RequestEntityTooLargeException e)
            {
                try
                {
                    await _rest.CreateMessage(req.ChannelId, new MessageRequest
                    {
                        Content = $"{Emojis.Error} One or more of the files attached to this message were not able to be proxied because they were too large.",
                        AllowedMentions = new AllowedMentions { Parse = { } },
                    });

                    throw new ProxyService.ProxyChecksFailedException("_internal_discord_rejected_message");
                }
                catch (Exception ex)
                {
                    if (ex.GetType() == typeof(ProxyService.ProxyChecksFailedException))
#pragma warning disable CA2200
                        throw ex;
#pragma warning restore CA2200
                    else
                        throw e;
                }
            }
            catch (JsonReaderException)
            {
                // This happens sometimes when we hit a CloudFlare error (or similar) on Discord's end
                // Nothing we can do about this - happens sometimes under server load, so just drop the message and give up
                throw new WebhookExecutionErrorOnDiscordsEnd();
            }
            catch (NotFoundException e)
            {
                if (e.ErrorCode == 10015 && !hasRetried)
                {
                    // Error 10015 = "Unknown Webhook" - this likely means the webhook was deleted
                    // but is still in our cache. Invalidate, refresh, try again
                    _logger.Warning("Error invoking webhook {Webhook} in channel {Channel} (thread {ThreadId})",
                        webhook.Id, webhook.ChannelId, req.ThreadId);

                    var newWebhook = await _webhookCache.InvalidateAndRefreshWebhook(req.ChannelId, webhook);
                    return await ExecuteWebhookInner(newWebhook, req, true);
                }

                throw;
            }
        }

        // We don't care about whether the sending succeeds, and we don't want to *wait* for it, so we just fork it off
        var _ = TrySendRemainingAttachments(webhook, req.Name, req.AvatarUrl, attachmentChunks, req.ThreadId);

        // for some reason discord may(?) return a null guildid here???
        return webhookMessage with { GuildId = webhookMessage.GuildId ?? req.GuildId };
    }

    private async Task TrySendRemainingAttachments(Webhook webhook, string name, string avatarUrl,
                                                   IReadOnlyList<IReadOnlyCollection<Message.Attachment>>
                                                       attachmentChunks, ulong? threadId)
    {
        if (attachmentChunks.Count <= 1) return;

        for (var i = 1; i < attachmentChunks.Count; i++)
        {
            var files = await GetAttachmentFiles(attachmentChunks[i]);
            var req = new ExecuteWebhookRequest
            {
                Username = name,
                AvatarUrl = avatarUrl,
                Attachments = files.Select(f => new Message.Attachment
                {
                    Id = (ulong)Array.IndexOf(files, f),
                    Description = f.Description,
                    Filename = f.Filename
                }).ToArray()
            };
            await _rest.ExecuteWebhook(webhook.Id, webhook.Token!, req, files, threadId);
        }
    }

    private async Task<MultipartFile[]> GetAttachmentFiles(IReadOnlyCollection<Message.Attachment> attachments)
    {
        async Task<MultipartFile> GetStream(Message.Attachment attachment)
        {
            var attachmentResponse =
                await _client.GetAsync(attachment.Url, HttpCompletionOption.ResponseHeadersRead);
            return new MultipartFile(attachment.Filename, await attachmentResponse.Content.ReadAsStreamAsync(),
                attachment.Description);
        }

        return await Task.WhenAll(attachments.Select(GetStream));
    }

    private IReadOnlyList<IReadOnlyCollection<Message.Attachment>> ChunkAttachmentsOrThrow(
        IReadOnlyList<Message.Attachment> attachments, int sizeThreshold)
    {
        // Splits a list of attachments into "chunks" of at most 8MB each
        // If any individual attachment is larger than 8MB, will throw an error
        var chunks = new List<List<Message.Attachment>>();
        var list = new List<Message.Attachment>();

        // sizeThreshold is in MB (user-readable)
        var bytesThreshold = sizeThreshold * 1024 * 1024;

        foreach (var attachment in attachments)
        {
            if (attachment.Size >= bytesThreshold) throw Errors.AttachmentTooLarge(sizeThreshold);

            if (list.Sum(a => a.Size) + attachment.Size >= bytesThreshold)
            {
                chunks.Add(list);
                list = new List<Message.Attachment>();
            }

            list.Add(attachment);
        }

        if (list.Count > 0) chunks.Add(list);
        return chunks;
    }

}

public static class ProxyNameExt
{
    public static string FixProxyName(this string name) => name
        .FixClyde()
        .FixHere()
        .FixEveryone()
        .FixDiscord()
        .FixBackticks()
        .FixSingleCharacterName();
    // .ThrowOnInvalidCharacters();

    static string ThrowOnInvalidCharacters(this string name)
    {
        var invalidCharacters = new[] { "@", "#", ":" };
        if (invalidCharacters.Any(x => name.Contains(x)))
            throw new PKError("Due to Discord limitations, proxy names cannot contain the characters `@`, `#` or `:`. "
                    + $"The webhook's name, {name.AsCode()}, contains one or more of these characters.");
        return name;
    }

    static string FixHere(this string name)
        => Regex.Replace(name, "^(h)(ere)$", Replacement, RegexOptions.IgnoreCase);

    static string FixEveryone(this string name)
        => Regex.Replace(name, "^(e)(veryone)$", Replacement, RegexOptions.IgnoreCase);

    static string FixBackticks(this string name)
        => Regex.Replace(name, "(`)(``)", Replacement, RegexOptions.IgnoreCase);

    static string FixDiscord(this string name)
        => Regex.Replace(name, "(d)(iscord)", Replacement, RegexOptions.IgnoreCase);

    // Adds a Unicode hair space (\u200A) between the "c" and the "lyde" to avoid Discord matching it
    // since Discord blocks webhooks containing the word "Clyde"... for some reason. /shrug
    static string FixClyde(this string name)
        => Regex.Replace(name, "(c)(lyde)", Replacement, RegexOptions.IgnoreCase);

    static string FixSingleCharacterName(this string proxyName)
    {
        if (proxyName.Length == 1)
            return proxyName + "\u17b5";
        return proxyName;
    }
    static string Replacement(Match m) => m.Groups[1].Value + "\u200A" + m.Groups[2].Value;
}