// Copyright (C) 2021-present sam (starshines.gay)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program.  If not, see <https://www.gnu.org/licenses/>.

using Catalogger.Backend.Cache;
using Catalogger.Backend.Cache.InMemoryCache;
using Catalogger.Backend.Database.Models;
using Catalogger.Backend.Database.Repositories;
using Catalogger.Backend.Extensions;
using Catalogger.Backend.Services;
using NodaTime.Extensions;
using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Gateway.Responders;
using Remora.Rest.Core;
using Remora.Results;

namespace Catalogger.Backend.Bot.Responders.Members;

public class GuildMemberUpdateResponder(
    ILogger logger,
    GuildRepository guildRepository,
    TimeoutRepository timeoutRepository,
    TimeoutService timeoutService,
    UserCache userCache,
    RoleCache roleCache,
    IMemberCache memberCache,
    WebhookExecutorService webhookExecutor,
    AuditLogCache auditLogCache
) : IResponder<IGuildMemberUpdate>
{
    private readonly ILogger _logger = logger.ForContext<GuildMemberUpdateResponder>();

    public async Task<Result> RespondAsync(
        IGuildMemberUpdate newMember,
        CancellationToken ct = default
    )
    {
        try
        {
            var oldMember = await memberCache.TryGetAsync(newMember.GuildID, newMember.User.ID);
            if (oldMember == null)
            {
                _logger.Information(
                    "Received member update event for {MemberId} in {GuildId} but they weren't cached, ignoring",
                    newMember.User.ID,
                    newMember.GuildID
                );
                return Result.Success;
            }

            var oldUser = oldMember.User.GetOrThrow();

            if (
                !Equals(oldMember.Avatar.OrDefault(), newMember.Avatar.OrDefault())
                || !Equals(oldUser.Avatar, newMember.User.Avatar)
            )
            {
                return await HandleAvatarUpdateAsync(newMember, oldMember, ct);
            }

            if (
                newMember.Nickname.OrDefault() != oldMember.Nickname.OrDefault()
                || newMember.User.Tag() != oldUser.Tag()
                || newMember.User.GlobalName.OrDefault() != oldUser.GlobalName.OrDefault()
            )
            {
                return await HandleNameUpdateAsync(newMember, oldMember, ct);
            }

            if (
                newMember.CommunicationDisabledUntil.OrDefault()
                != oldMember.CommunicationDisabledUntil.OrDefault()
            )
            {
                return await HandleTimeoutAsync(newMember, ct);
            }

            if (
                newMember.Roles.Except(oldMember.Roles).Any()
                || oldMember.Roles.Except(newMember.Roles).Any()
            )
            {
                return await HandleRoleUpdateAsync(newMember, oldMember.Roles, ct);
            }
        }
        finally
        {
            await memberCache.UpdateAsync(newMember);

            userCache.UpdateUser(newMember.User);
        }

        return Result.Success;
    }

    private async Task<Result> HandleAvatarUpdateAsync(
        IGuildMemberUpdate newMember,
        IGuildMember oldMember,
        CancellationToken ct = default
    )
    {
        IEmbed embed;

        if (!Equals(oldMember.Avatar.OrDefault(), newMember.Avatar.OrDefault()))
        {
            var builder = new EmbedBuilder()
                .WithAuthor(newMember.User.Tag(), null, newMember.User.AvatarUrl())
                .WithColour(DiscordUtils.Green)
                .WithFooter($"User ID: {newMember.User.ID}")
                .WithCurrentTimestamp();

            if (newMember.Avatar.IsDefined())
            {
                builder = builder
                    .WithTitle("Changed server avatar")
                    .WithThumbnailUrl(newMember.AvatarUrl(1024)!);
            }
            else
            {
                builder = builder.WithTitle("Removed server avatar");
            }

            embed = builder.Build().GetOrThrow();
        }
        else
        {
            embed = new EmbedBuilder()
                .WithAuthor(newMember.User.Tag(), null, newMember.User.AvatarUrl())
                .WithTitle("Changed avatar")
                .WithThumbnailUrl(newMember.User.AvatarUrl(1024))
                .WithColour(DiscordUtils.Green)
                .WithFooter($"User ID: {newMember.User.ID}")
                .WithCurrentTimestamp()
                .Build()
                .GetOrThrow();
        }

        var guildConfig = await guildRepository.GetAsync(newMember.GuildID);
        webhookExecutor.QueueLog(guildConfig, LogChannelType.GuildMemberAvatarUpdate, embed);
        return Result.Success;
    }

    private async Task<Result> HandleNameUpdateAsync(
        IGuildMemberUpdate newMember,
        IGuildMember oldMember,
        CancellationToken ct = default
    )
    {
        var oldUser = oldMember.User.GetOrThrow();

        var builder = new EmbedBuilder()
            .WithAuthor(newMember.User.Tag(), null, newMember.User.AvatarUrl())
            .WithColour(DiscordUtils.Green)
            .WithFooter($"User ID: {newMember.User.ID}")
            .WithCurrentTimestamp();

        if (newMember.Nickname.OrDefault() != oldMember.Nickname.OrDefault())
        {
            builder.AddField(
                "Changed nickname",
                $"""
                **Before:** {oldMember.Nickname.OrDefault("*(none)*")}
                **After:** {newMember.Nickname.OrDefault("*(none)*")}
                """
            );
        }

        if (newMember.User.GlobalName.OrDefault() != oldUser.GlobalName.OrDefault())
        {
            builder.AddField(
                "Changed display name",
                $"""
                **Before:** {oldUser.GlobalName.OrDefault("*(none)*")}
                **After:** {newMember.User.GlobalName.OrDefault("*(none)*")}
                """
            );
        }

        if (newMember.User.Tag() != oldUser.Tag())
        {
            builder.AddField(
                "Changed username",
                $"""
                **Before:** {oldUser.Tag()}
                **After:** {newMember.User.Tag()}
                """
            );

            await memberCache.UpdateMemberNameAsync(
                newMember.GuildID,
                newMember.User.ID,
                oldUser.Tag(),
                newMember.User.Tag()
            );
        }

        var guildConfig = await guildRepository.GetAsync(newMember.GuildID);
        webhookExecutor.QueueLog(
            guildConfig,
            LogChannelType.GuildMemberNickUpdate,
            builder.Build().GetOrThrow()
        );
        return Result.Success;
    }

    private async Task<Result> HandleTimeoutAsync(
        IGuildMemberUpdate member,
        CancellationToken ct = default
    )
    {
        // Delay 2 seconds to make sure the timeout audit log got cached
        await Task.Delay(2000, ct);

        var timeoutUntil = member.CommunicationDisabledUntil.OrDefault();

        var embed = new EmbedBuilder()
            .WithAuthor(member.User.Tag(), null, member.User.AvatarUrl())
            .WithTitle(
                timeoutUntil != null ? "Member timed out" : "Member removed from timeout early"
            )
            .WithDescription($"<@{member.User.ID}>")
            .WithColour(DiscordUtils.Red)
            .WithFooter($"User ID: {member.User.ID}")
            .WithCurrentTimestamp();

        if (timeoutUntil != null)
        {
            embed.AddField(
                "Until",
                $"<t:{timeoutUntil.Value.ToUnixTimeSeconds()}>\nin {timeoutUntil.Value.AddSeconds(5).Prettify()}"
            );
        }

        if (auditLogCache.TryGetMemberUpdate(member.GuildID, member.User.ID, out var actionData))
        {
            var moderator = await userCache.TryFormatUserAsync(actionData.ModeratorId);
            embed.AddField("Responsible moderator", moderator);
            embed.AddField("Reason", actionData.Reason ?? "No reason given");

            await UpdateTimeoutDatabaseAsync(member, actionData.ModeratorId);
        }
        else
        {
            embed.AddField("Responsible moderator", "*(unknown)*");
            embed.AddField("Reason", "*(unknown)*");

            await UpdateTimeoutDatabaseAsync(member, null);
        }

        var guildConfig = await guildRepository.GetAsync(member.GuildID);
        webhookExecutor.QueueLog(
            guildConfig,
            LogChannelType.GuildMemberTimeout,
            embed.Build().GetOrThrow()
        );
        return Result.Success;
    }

    private async Task UpdateTimeoutDatabaseAsync(IGuildMemberUpdate member, Snowflake? moderatorId)
    {
        var until = member.CommunicationDisabledUntil.OrDefault();
        if (until == null)
        {
            // timeout was ended early, delete database entry
            var oldTimeout = await timeoutRepository.RemoveAsync(member.GuildID, member.User.ID);
            if (oldTimeout != null)
                timeoutService.RemoveTimer(oldTimeout.Id);
            return;
        }

        var dbTimeout = await timeoutRepository.SetAsync(
            member.GuildID,
            member.User.ID,
            until.Value.ToInstant(),
            moderatorId
        );
        timeoutService.AddTimer(dbTimeout);
    }

    private async Task<Result> HandleRoleUpdateAsync(
        IGuildMemberUpdate member,
        IReadOnlyList<Snowflake> oldRoles,
        CancellationToken ct = default
    )
    {
        var guildConfig = await guildRepository.GetAsync(member.GuildID);
        var guildRoles = roleCache.GuildRoles(member.GuildID).ToList();

        var keyRoleUpdate = new EmbedBuilder()
            .WithAuthor(member.User.Tag(), null, member.User.AvatarUrl())
            .WithTitle("Key roles added or removed")
            .WithDescription($"<@{member.User.ID}>")
            .WithColour(DiscordUtils.Purple)
            .WithFooter($"User ID: {member.User.ID}")
            .WithCurrentTimestamp();

        var roleUpdate = new EmbedBuilder()
            .WithAuthor(member.User.Tag(), null, member.User.AvatarUrl())
            .WithTitle("Roles added or removed")
            .WithDescription($"<@{member.User.ID}>")
            .WithColour(DiscordUtils.Purple)
            .WithFooter($"User ID: {member.User.ID}")
            .WithCurrentTimestamp();

        var addedRoles = member.Roles.Except(oldRoles).ToList();
        var removedRoles = oldRoles.Except(member.Roles).ToList();

        if (addedRoles.Count != 0)
        {
            roleUpdate.AddField("Added", string.Join(", ", addedRoles.Select(id => $"<@&{id}>")));

            // Add all added key roles to the log
            if (!addedRoles.Select(s => s.Value).Except(guildConfig.KeyRoles).Any())
            {
                var value = string.Join(
                    "\n",
                    addedRoles
                        .Select(s => s.Value)
                        .Where(guildConfig.KeyRoles.Contains)
                        .Select(id =>
                        {
                            var role = guildRoles.FirstOrDefault(r => r.ID.Value == id);
                            return role != null ? $"{role.Name} <@&{role.ID}>" : $"<@&{id}>";
                        })
                );

                keyRoleUpdate.AddField("Added", value);
            }
        }

        if (removedRoles.Count != 0)
        {
            roleUpdate.AddField(
                "Removed",
                string.Join(", ", removedRoles.Select(id => $"<@&{id}>"))
            );

            // Add all removed key roles to the log
            if (!removedRoles.Select(s => s.Value).Except(guildConfig.KeyRoles).Any())
            {
                var value = string.Join(
                    "\n",
                    removedRoles
                        .Select(s => s.Value)
                        .Where(guildConfig.KeyRoles.Contains)
                        .Select(id =>
                        {
                            var role = guildRoles.FirstOrDefault(r => r.ID.Value == id);
                            return role != null ? $"{role.Name} <@&{role.ID}>" : $"<@&{id}>";
                        })
                );

                keyRoleUpdate.AddField("Removed", value);
            }
        }

        // If there are any fields in the role update embed, we should send it
        if (roleUpdate.Fields.Count != 0)
        {
            webhookExecutor.QueueLog(
                webhookExecutor.GetLogChannel(
                    guildConfig,
                    LogChannelType.GuildMemberUpdate,
                    // Check for all added and removed roles
                    roleIds: addedRoles.Concat(removedRoles).ToList()
                ),
                roleUpdate.Build().GetOrThrow()
            );
        }

        // Do the same for the key role update embed, but we also need to fetch the moderator that updated them
        if (keyRoleUpdate.Fields.Count != 0)
        {
            if (
                auditLogCache.TryGetMemberUpdate(member.GuildID, member.User.ID, out var actionData)
            )
            {
                var moderator = await userCache.TryFormatUserAsync(actionData.ModeratorId);
                keyRoleUpdate.AddField("Responsible moderator", moderator);
            }
            else
            {
                keyRoleUpdate.AddField("Responsible moderator", "*(unknown)*");
            }

            // Finally, send the embed
            webhookExecutor.QueueLog(
                guildConfig,
                LogChannelType.GuildKeyRoleUpdate,
                keyRoleUpdate.Build().GetOrThrow()
            );
        }

        return Result.Success;
    }
}
