// 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.Repositories;
using Catalogger.Backend.Extensions;
using Catalogger.Backend.Services;
using Humanizer;
using Remora.Discord.API;
using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.API.Objects;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Gateway.Responders;
using Remora.Results;

namespace Catalogger.Backend.Bot.Responders.Members;

public class GuildMemberAddResponder(
    ILogger logger,
    InviteRepository inviteRepository,
    GuildRepository guildRepository,
    WatchlistRepository watchlistRepository,
    IMemberCache memberCache,
    IInviteCache inviteCache,
    UserCache userCache,
    WebhookExecutorService webhookExecutor,
    IDiscordRestGuildAPI guildApi,
    PluralkitApiService pluralkitApi
) : IResponder<IGuildMemberAdd>
{
    private readonly ILogger _logger = logger.ForContext<GuildMemberAddResponder>();
    private static readonly TimeSpan NewAccountThreshold = 7.Days();

    public async Task<Result> RespondAsync(IGuildMemberAdd member, CancellationToken ct = default)
    {
        await memberCache.SetAsync(member.GuildID, member);
        await memberCache.SetMemberNamesAsync(member.GuildID, [member]);

        var user = member.User.GetOrThrow();
        userCache.UpdateUser(user);

        var builder = new EmbedBuilder()
            .WithTitle("Member joined")
            .WithColour(DiscordUtils.Green)
            .WithAuthor(user.Tag(), null, user.AvatarUrl())
            .WithDescription($"<@{user.ID}>")
            .WithCurrentTimestamp()
            .WithFooter($"ID: {user.ID}");

        var guildConfig = await guildRepository.GetAsync(member.GuildID);
        var guildRes = await guildApi.GetGuildAsync(member.GuildID, withCounts: true, ct);
        if (guildRes.IsSuccess && guildRes.Entity.ApproximateMemberCount.IsDefined())
            builder.Description +=
                $"\n{guildRes.Entity.ApproximateMemberCount.OrDefault(1).Ordinalize()} to join";

        builder.Description +=
            $"\ncreated {user.ID.Timestamp.Prettify()} ago\n<t:{user.ID.Timestamp.ToUnixTimeSeconds()}:F>";

        var pkSystem = await pluralkitApi.GetPluralKitSystemAsync(user.ID.Value, ct);
        if (pkSystem != null)
        {
            var createdAt =
                pkSystem.Created != null
                    ? $"{pkSystem.Created.Value.Prettify()} ago (<t:{pkSystem.Created.Value.ToUnixTimeSeconds()}:F>)"
                    : "*(unknown)*";
            builder.AddField(
                "PluralKit system",
                $"""
                **ID:** {pkSystem.Id} (`{pkSystem.Uuid}`)
                **Name:** {pkSystem.Name ?? "*(none)*"}
                **Tag:** {pkSystem.Tag ?? "*(none)*"}
                **Created:** {createdAt}
                """
            );
        }

        // Bots don't use invites, so the entire following block is useless
        if (user.IsBot.OrDefault())
        {
            goto afterInvite;
        }

        var existingInvites = (await inviteCache.TryGetAsync(member.GuildID)).ToList();
        var newInvitesRes = await guildApi.GetGuildInvitesAsync(member.GuildID, ct);
        if (!newInvitesRes.IsSuccess)
        {
            _logger.Error(
                "Could not fetch invites for guild {GuildId}: {Error}",
                member.GuildID,
                newInvitesRes.Error
            );

            goto afterInvite;
        }

        // Update the invite cache immediately--we've already fetched a copy of the invites, after all
        await inviteCache.SetAsync(member.GuildID, newInvitesRes.Entity);

        // If we can't find a used invite, and the guild has a vanity link, that invite was used.
        // Otherwise, we give up
        var usedInvite = FindUsedInvite(existingInvites, newInvitesRes.Entity);
        if (usedInvite == null)
        {
            builder.AddField(
                "Invite used",
                guildRes is { IsSuccess: true, Entity.VanityUrlCode: not null }
                    ? $"Vanity invite (`{guildRes.Entity.VanityUrlCode}`)"
                    : "*Could not determine invite, sorry.*"
            );

            goto afterInvite;
        }

        var inviteName = await inviteRepository.GetInviteNameAsync(member.GuildID, usedInvite.Code);

        var inviteDescription = $"""
            **Code:** {usedInvite.Code}
            **Name:** {inviteName}
            **Uses:** {usedInvite.Uses}
            **Created at:** <t:{usedInvite.CreatedAt.ToUnixTimeSeconds()}>
            """;

        if (usedInvite.Inviter.IsDefined(out var inviter))
            inviteDescription += $"\n**Created by:** {inviter.Tag()} <@{inviter.ID}>";

        builder.AddField("Invite used", inviteDescription);

        afterInvite:

        List<Embed> embeds = [builder.Build().GetOrThrow()];

        if (user.ID.Timestamp > DateTimeOffset.Now - NewAccountThreshold)
        {
            embeds.Add(
                new EmbedBuilder()
                    .WithTitle("New account")
                    .WithColour(DiscordUtils.Orange)
                    .WithDescription($"\u26a0\ufe0f Created {user.ID.Timestamp.Prettify()} ago")
                    .Build()
                    .GetOrThrow()
            );
        }

        var watchlist = await watchlistRepository.GetEntryAsync(member.GuildID, user.ID);
        if (watchlist != null)
        {
            var moderator = await userCache.GetUserAsync(
                DiscordSnowflake.New(watchlist.ModeratorId)
            );
            var mod =
                moderator != null
                    ? $"{moderator.Tag()} (<@{moderator.ID}>)"
                    : $"<@{watchlist.ModeratorId}>";

            embeds.Add(
                new EmbedBuilder()
                    .WithTitle("⚠️ User on watchlist")
                    .WithColour(DiscordUtils.Red)
                    .WithDescription(
                        $"**{user.Tag()}** is on this server's watch list.\n\n{watchlist.Reason}"
                    )
                    .WithFooter($"ID: {user.ID} | Added")
                    .WithTimestamp(watchlist.AddedAt.ToDateTimeOffset())
                    .AddField("Moderator", mod)
                    .GetOrThrow()
                    .Build()
                    .GetOrThrow()
            );
        }

        if (pkSystem != null && guildConfig.IsSystemBanned(pkSystem))
        {
            embeds.Add(
                new EmbedBuilder()
                    .WithTitle("Banned system")
                    .WithDescription(
                        "\u26a0\ufe0f The system associated with this account has been banned from the server."
                    )
                    .WithColour(DiscordUtils.Red)
                    .WithFooter($"ID: {pkSystem.Id}")
                    .Build()
                    .GetOrThrow()
            );
        }

        if (embeds.Count > 1)
            await webhookExecutor.SendLogAsync(
                guildConfig.Channels.GuildMemberAdd,
                embeds.Cast<IEmbed>().ToList(),
                []
            );
        else
            webhookExecutor.QueueLog(guildConfig.Channels.GuildMemberAdd, embeds[0]);

        return Result.Success;
    }

    private static IInviteWithMetadata? FindUsedInvite(
        List<IInviteWithMetadata> existingInvites,
        IReadOnlyList<IInviteWithMetadata> newInvites
    )
    {
        // First, we check all invites in *both* lists, and look for one that has more uses now than before.
        // If one matches that, it's probably the used invite.
        var usedInvite = existingInvites.FirstOrDefault(e =>
            newInvites.Any(n => e.Code == n.Code && e.Uses < n.Uses)
        );
        if (usedInvite != null)
            return usedInvite;

        // Then we check all new invites (i.e. ones that don't exist in the old list, but do in the new one)
        // and check for one that has one use.
        usedInvite = newInvites.FirstOrDefault(n =>
            existingInvites.All(e => e.Code != n.Code) && n.Uses == 1
        );
        if (usedInvite != null)
            return usedInvite;

        // Finally, we check invites that exist in the old list but not the new one, and were one use away from expiry.
        // If one matches, we can safely say it was the used invite.
        return existingInvites.FirstOrDefault(e =>
            newInvites.All(n => n.Code != e.Code) && e.MaxUses == e.Uses - 1
        );
    }
}
