using System.Collections.Concurrent;
using System.Numerics;
using System.Text.Json;
using System.Text.Json.Serialization;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface.Textures;
using Dalamud.Interface.Textures.TextureWraps;
using Dalamud.Utility;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;

namespace HellionChat;

public static class EmoteCache
{
    private static readonly string[] NotWorking =
    [
        ":tf:",
        "(ditto)",
        "c!",
        "h!",
        "l!",
        "M&Mjc",
        "LUL3D",
        "p!",
        "POLICE2",
        "r!",
        "Pussy",
        "s!",
        "v!",
        "w!",
        "x0r6ztGiggle",
        "z!",
        "xar2EDM",
        "iron95Pls",
        "Clap2",
        "AlienPls3",
        "Life",
        "peepoPogClimbingTreeHard4House",
        "monkaGIGAftRobertDowneyJr",
        "DogLookingSussyAndCold",
        "DICKS",
    ];

    private static readonly HttpClient Client = new();

    private const string BetterTTV = "https://api.betterttv.net/3";
    private const string GlobalEmotes = $"{BetterTTV}/cached/emotes/global";
    private const string Top100Emotes = "{0}/emotes/shared/top?before={1}&limit=100";
    private const string EmotePath = "https://cdn.betterttv.net/emote/{0}/3x";

    [Serializable]
    private struct Top100()
    {
        [JsonPropertyName("emote")]
        public Emote Emote { get; set; }

        [JsonPropertyName("id")]
        public required string Id { get; set; }
    }

    [Serializable]
    public struct Emote()
    {
        [JsonPropertyName("id")]
        public required string Id { get; set; }

        [JsonPropertyName("code")]
        public required string Code { get; set; }

        [JsonPropertyName("imageType")]
        public required string ImageType { get; set; }
    }

    public enum LoadingState
    {
        Unloaded,
        Loading,
        Done,
    }

    // All fields below are uninitialised while State != Done.
    public static LoadingState State = LoadingState.Unloaded;

    private static readonly Dictionary<string, Emote> Cache = new();
    private static readonly Dictionary<string, EmoteBase> EmoteImages = new();

    public static string[] SortedCodeArray = [];

    // Cancelled on Dispose to stop in-flight downloads; replaced on re-enable.
    private static CancellationTokenSource Cts = new();
    internal static CancellationToken Token => Cts.Token;

    // Tracks in-flight loads so Dispose can drain them before teardown.
    private static readonly ConcurrentBag<Task> PendingLoads = new();

    internal static void TrackLoad(Task loadTask, string emoteCode)
    {
        PendingLoads.Add(
            loadTask.ContinueWith(
                t =>
                {
                    if (t.IsFaulted)
                        Plugin.LogProxy.Error(
                            t.Exception!,
                            $"EmoteCache load failed for {emoteCode}"
                        );
                },
                TaskScheduler.Default
            )
        );
    }

    public static async Task LoadData()
    {
        if (State is not LoadingState.Unloaded)
            return;

        // Reset CTS if Dispose was called and the plugin is being re-enabled.
        if (Cts.IsCancellationRequested)
            Cts = new CancellationTokenSource();

        State = LoadingState.Loading;
        var ct = Cts.Token;
        try
        {
            var global = await Client.GetAsync(GlobalEmotes, ct);
            var globalList = await global.Content.ReadAsStringAsync(ct);

            foreach (var emote in JsonSerializer.Deserialize<Emote[]>(globalList)!)
                if (!string.IsNullOrEmpty(emote.Code) && !NotWorking.Contains(emote.Code))
                    Cache.TryAdd(emote.Code, emote);

            var lastId = string.Empty;
            for (var i = 0; i < 15; i++)
            {
                var top = await Client.GetAsync(Top100Emotes.Format(BetterTTV, lastId), ct);
                var topList = await top.Content.ReadAsStringAsync(ct);

                var jsonList = JsonSerializer.Deserialize<List<Top100>>(topList)!;
                // BetterTTV occasionally returns entries with a null Code;
                // skip them so a single bad row doesn't break the whole cache.
                foreach (var emote in jsonList)
                    if (
                        !string.IsNullOrEmpty(emote.Emote.Code)
                        && !NotWorking.Contains(emote.Emote.Code)
                    )
                        Cache.TryAdd(emote.Emote.Code, emote.Emote);

                lastId = jsonList.Last().Id;
            }

            SortedCodeArray = Cache.Keys.Order().ToArray();
            State = LoadingState.Done;
        }
        catch (OperationCanceledException)
        {
            // Plugin disposed mid-load; State stays on Loading so re-enable can retry.
        }
        catch (Exception ex)
        {
            // Reset to Unloaded so a later trigger can retry without a plugin reload.
            State = LoadingState.Unloaded;
            Plugin.LogProxy.Error(ex, "BetterTTV cache wasn't initialized");
        }
    }

    public static void Dispose()
    {
        Cts.Cancel();

        // 5s upper bound; anything still running gets abandoned.
        try
        {
            Task.WaitAll(PendingLoads.ToArray(), TimeSpan.FromSeconds(5));
        }
        catch (AggregateException)
        {
            // Faults already logged in TrackLoad.
        }

        while (PendingLoads.TryTake(out _)) { }

        foreach (var emote in EmoteImages.Values)
            emote.InnerDispose();
    }

    internal static bool Exists(string code)
    {
        return State is LoadingState.Done && SortedCodeArray.Contains(code);
    }

    internal static EmoteBase? GetEmote(string code)
    {
        if (State is not LoadingState.Done)
            return null;

        if (!Cache.TryGetValue(code, out var emoteDetail))
            return null;

        if (EmoteImages.TryGetValue(emoteDetail.Id, out var emote))
            return emote;

        try
        {
            if (emoteDetail.ImageType == "gif")
            {
                var animatedEmote = new ImGuiGif().Prepare(emoteDetail);
                EmoteImages.Add(emoteDetail.Id, animatedEmote);
                return animatedEmote;
            }

            var staticEmote = new ImGuiEmote().Prepare(emoteDetail);
            EmoteImages.Add(emoteDetail.Id, staticEmote);

            return staticEmote;
        }
        catch
        {
            Plugin.LogProxy.Error("Failed to convert");
            return null;
        }
    }

    public abstract class EmoteBase
    {
        public bool Failed;
        public bool IsLoaded;

        public byte[] RawData = [];

        protected IDalamudTextureWrap? Texture;

        public virtual void Draw(Vector2 size)
        {
            ImGui.Image(Texture!.Handle, size);
        }

        internal async Task<byte[]> LoadAsync(Emote emote, CancellationToken ct)
        {
            // Path-traversal guard: resolve and verify the candidate path stays
            // inside the cache directory before reading or writing.
            var dir = Path.GetFullPath(
                Path.Join(Plugin.Interface.ConfigDirectory.FullName, "EmoteCacheV1")
            );
            Directory.CreateDirectory(dir);

            var dirPrefix = dir.EndsWith(Path.DirectorySeparatorChar)
                ? dir
                : dir + Path.DirectorySeparatorChar;
            var filePath = Path.GetFullPath(Path.Join(dir, $"{emote.Id}.{emote.ImageType}"));
            if (!filePath.StartsWith(dirPrefix, StringComparison.Ordinal))
                throw new InvalidOperationException(
                    $"Emote path escapes cache directory: id={emote.Id}, type={emote.ImageType}"
                );

            if (File.Exists(filePath))
            {
                RawData = await File.ReadAllBytesAsync(filePath, ct);
            }
            else
            {
                var content = await Client.GetAsync(EmotePath.Format(emote.Id), ct);
                RawData = await content.Content.ReadAsByteArrayAsync(ct);

                await using var stream = new FileStream(
                    filePath,
                    FileMode.Create,
                    FileAccess.Write,
                    FileShare.Read
                );
                await stream.WriteAsync(RawData, ct);
            }

            return RawData;
        }

        public abstract void InnerDispose();
    }

    public sealed class ImGuiEmote : EmoteBase
    {
        public ImGuiEmote Prepare(Emote emote)
        {
            var ct = EmoteCache.Token;
            // Task.Run keeps the sync prefix off the ImGui render thread.
            EmoteCache.TrackLoad(Task.Run(() => LoadAsyncTracked(emote, ct), ct), emote.Code);
            return this;
        }

        private async Task LoadAsyncTracked(Emote emote, CancellationToken ct)
        {
            try
            {
                var image = await LoadAsync(emote, ct);
                if (image.Length <= 0)
                    return;

                ct.ThrowIfCancellationRequested();
                Texture = await Plugin.TextureProvider.CreateFromImageAsync(
                    image,
                    cancellationToken: ct
                );
                IsLoaded = true;
            }
            catch (OperationCanceledException) { }
            catch (Exception ex)
            {
                Failed = true;
                Plugin.LogProxy.Error(ex, $"Unable to load {emote.Code} with id {emote.Id}");
            }
        }

        public override void InnerDispose()
        {
            Texture?.Dispose();
        }
    }

    public sealed class ImGuiGif : EmoteBase
    {
        private List<(IDalamudTextureWrap Texture, float Delay)> Frames = [];
        private float FrameTimer;
        private int CurrentFrame;
        private ulong GlobalFrameCount;

        public override void Draw(Vector2 size)
        {
            if (Frames.Count == 0)
                return;

            if (CurrentFrame >= Frames.Count)
            {
                CurrentFrame = 0;
                FrameTimer = -1f;
            }

            var frame = Frames[CurrentFrame];
            if (FrameTimer <= 0.0f)
                FrameTimer = frame.Delay;

            ImGui.Image(frame.Texture.Handle, size);

            if (GlobalFrameCount == Plugin.Interface.UiBuilder.FrameCount)
                return;

            GlobalFrameCount = Plugin.Interface.UiBuilder.FrameCount;

            FrameTimer -= ImGui.GetIO().DeltaTime;
            if (FrameTimer <= 0f)
                CurrentFrame++;
        }

        public override void InnerDispose()
        {
            Frames.ForEach(f => f.Texture.Dispose());
            Frames.Clear();
        }

        public ImGuiGif Prepare(Emote emote)
        {
            var ct = EmoteCache.Token;
            EmoteCache.TrackLoad(Task.Run(() => LoadAsyncTracked(emote, ct), ct), emote.Code);
            return this;
        }

        private async Task LoadAsyncTracked(Emote emote, CancellationToken ct)
        {
            try
            {
                var image = await LoadAsync(emote, ct);
                if (image.Length <= 0)
                    return;

                using var ms = new MemoryStream(image);
                using var img = Image.Load<Rgba32>(ms);
                if (img.Frames.Count == 0)
                    return;

                var frames = new List<(IDalamudTextureWrap Tex, float Delay)>();
                foreach (var frame in img.Frames)
                {
                    ct.ThrowIfCancellationRequested();

                    var delay = frame.Metadata.GetGifMetadata().FrameDelay / 100f;

                    // Match browser behaviour: anything under 20ms rounds up to 100ms.
                    if (delay < 0.02f)
                        delay = 0.1f;

                    var buffer = new byte[4 * frame.Width * frame.Height];
                    frame.CopyPixelDataTo(buffer);
                    var tex = await Plugin.TextureProvider.CreateFromRawAsync(
                        RawImageSpecification.Rgba32(frame.Width, frame.Height),
                        buffer,
                        cancellationToken: ct
                    );
                    frames.Add((tex, delay));
                }

                Frames = frames;
                IsLoaded = true;
            }
            catch (OperationCanceledException)
            {
                // Plugin disposed mid-load; release any partial frames.
                foreach (var f in Frames)
                    f.Texture.Dispose();
                Frames = [];
            }
            catch (Exception ex)
            {
                Failed = true;
                Plugin.LogProxy.Error(ex, $"Unable to load {emote.Code} with id {emote.Id}");
            }
        }
    }
}
