#nullable disable
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using EllieBot.Common.ModuleBehaviors;
using EllieBot.Common.TypeReaders.Models;
using EllieBot.Modules.Permissions.Services;
using EllieBot.Db.Models;
using Newtonsoft.Json;

namespace EllieBot.Modules.Administration.Services;

public class UserPunishService : IEService, IReadyExecutor
{
    private readonly MuteService _mute;
    private readonly DbService _db;
    private readonly BlacklistService _blacklistService;
    private readonly BotConfigService _bcs;
    private readonly DiscordSocketClient _client;
    private readonly IReplacementService _repSvc;

    public event Func<Warning, Task> OnUserWarned = static delegate { return Task.CompletedTask; };

    public UserPunishService(
        MuteService mute,
        DbService db,
        BlacklistService blacklistService,
        BotConfigService bcs,
        DiscordSocketClient client,
        IReplacementService repSvc)
    {
        _mute = mute;
        _db = db;
        _blacklistService = blacklistService;
        _bcs = bcs;
        _client = client;
        _repSvc = repSvc;
    }

    public async Task OnReadyAsync()
    {
        if (_client.ShardId != 0)
            return;

        using var expiryTimer = new PeriodicTimer(TimeSpan.FromHours(12));
        do
        {
            try
            {
                await CheckAllWarnExpiresAsync();
            }
            catch (Exception ex)
            {
                Log.Error(ex, "Unexpected error while checking for warn expiries: {ErrorMessage}", ex.Message);
            }
        } while (await expiryTimer.WaitForNextTickAsync());
    }

    public async Task<WarningPunishment> Warn(
        IGuild guild,
        ulong userId,
        IUser mod,
        long weight,
        string reason)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(weight);

        var modName = mod.ToString();

        if (string.IsNullOrWhiteSpace(reason))
            reason = "-";

        var guildId = guild.Id;

        var warn = new Warning
        {
            UserId = userId,
            GuildId = guildId,
            Forgiven = false,
            Reason = reason,
            Moderator = modName,
            Weight = weight
        };

        long previousCount;
        List<WarningPunishment> ps;
        await using (var uow = _db.GetDbContext())
        {
            ps = uow.GuildConfigsForId(guildId, set => set.Include(x => x.WarnPunishments)).WarnPunishments;

            previousCount = uow.Set<Warning>()
                               .ForId(guildId, userId)
                               .Where(w => !w.Forgiven && w.UserId == userId)
                               .Sum(x => x.Weight);

            uow.Set<Warning>().Add(warn);

            await uow.SaveChangesAsync();
        }

        _ = OnUserWarned(warn);

        var totalCount = previousCount + weight;

        var p = ps.Where(x => x.Count > previousCount && x.Count <= totalCount)
                  .MaxBy(x => x.Count);

        if (p is not null)
        {
            var user = await guild.GetUserAsync(userId);
            if (user is null)
                return null;

            await ApplyPunishment(guild, user, mod, p.Punishment, p.Time, p.RoleId, "Warned too many times.");
            return p;
        }

        return null;
    }

    public async Task ApplyPunishment(
        IGuild guild,
        IGuildUser user,
        IUser mod,
        PunishmentAction p,
        int minutes,
        ulong? roleId,
        string reason)
    {
        if (!await CheckPermission(guild, p))
            return;

        int banPrune;
        switch (p)
        {
            case PunishmentAction.Mute:
                if (minutes == 0)
                    await _mute.MuteUser(user, mod, reason: reason);
                else
                    await _mute.TimedMute(user, mod, TimeSpan.FromMinutes(minutes), reason: reason);
                break;
            case PunishmentAction.VoiceMute:
                if (minutes == 0)
                    await _mute.MuteUser(user, mod, MuteType.Voice, reason);
                else
                    await _mute.TimedMute(user, mod, TimeSpan.FromMinutes(minutes), MuteType.Voice, reason);
                break;
            case PunishmentAction.ChatMute:
                if (minutes == 0)
                    await _mute.MuteUser(user, mod, MuteType.Chat, reason);
                else
                    await _mute.TimedMute(user, mod, TimeSpan.FromMinutes(minutes), MuteType.Chat, reason);
                break;
            case PunishmentAction.Kick:
                await user.KickAsync(reason);
                break;
            case PunishmentAction.Ban:
                banPrune = await GetBanPruneAsync(user.GuildId) ?? 7;
                if (minutes == 0)
                    await guild.AddBanAsync(user, reason: reason, pruneDays: banPrune);
                else
                    await _mute.TimedBan(user.Guild, user.Id, TimeSpan.FromMinutes(minutes), reason, banPrune);
                break;
            case PunishmentAction.Softban:
                banPrune = await GetBanPruneAsync(user.GuildId) ?? 7;
                await guild.AddBanAsync(user, banPrune, $"Softban | {reason}");
                try
                {
                    await guild.RemoveBanAsync(user);
                }
                catch
                {
                    await guild.RemoveBanAsync(user);
                }

                break;
            case PunishmentAction.RemoveRoles:
                await user.RemoveRolesAsync(user.GetRoles().Where(x => !x.IsManaged && x != x.Guild.EveryoneRole));
                break;
            case PunishmentAction.AddRole:
                if (roleId is null)
                    return;
                var role = guild.GetRole(roleId.Value);
                if (role is not null)
                {
                    if (minutes == 0)
                        await user.AddRoleAsync(role);
                    else
                        await _mute.TimedRole(user, TimeSpan.FromMinutes(minutes), reason, role);
                }
                else
                {
                    Log.Warning("Can't find role {RoleId} on server {GuildId} to apply punishment",
                        roleId.Value,
                        guild.Id);
                }

                break;
            case PunishmentAction.Warn:
                await Warn(guild, user.Id, mod, 1, reason);
                break;
            case PunishmentAction.TimeOut:
                await user.SetTimeOutAsync(TimeSpan.FromMinutes(minutes));
                break;
        }
    }

    /// <summary>
    ///     Used to prevent the bot from hitting 403's when it needs to
    ///     apply punishments with insufficient permissions
    /// </summary>
    /// <param name="guild">Guild the punishment is applied in</param>
    /// <param name="punish">Punishment to apply</param>
    /// <returns>Whether the bot has sufficient permissions</returns>
    private async Task<bool> CheckPermission(IGuild guild, PunishmentAction punish)
    {
        var botUser = await guild.GetCurrentUserAsync();
        switch (punish)
        {
            case PunishmentAction.Mute:
                return botUser.GuildPermissions.MuteMembers && botUser.GuildPermissions.ManageRoles;
            case PunishmentAction.Kick:
                return botUser.GuildPermissions.KickMembers;
            case PunishmentAction.Ban:
                return botUser.GuildPermissions.BanMembers;
            case PunishmentAction.Softban:
                return botUser.GuildPermissions.BanMembers; // ban + unban
            case PunishmentAction.RemoveRoles:
                return botUser.GuildPermissions.ManageRoles;
            case PunishmentAction.ChatMute:
                return botUser.GuildPermissions.ManageRoles; // adds ellie-mute role
            case PunishmentAction.VoiceMute:
                return botUser.GuildPermissions.MuteMembers;
            case PunishmentAction.AddRole:
                return botUser.GuildPermissions.ManageRoles;
            case PunishmentAction.TimeOut:
                return botUser.GuildPermissions.ModerateMembers;
            default:
                return true;
        }
    }

    public async Task CheckAllWarnExpiresAsync()
    {
        await using var uow = _db.GetDbContext();

        var toClear = await uow.GetTable<Warning>()
                               .Where(x => uow.GetTable<GuildConfig>()
                                              .Count(y => y.GuildId == x.GuildId
                                                          && y.WarnExpireHours > 0
                                                          && y.WarnExpireAction == WarnExpireAction.Clear)
                                           > 0
                                           && x.Forgiven == false
                                           && x.DateAdded
                                           < DateTime.UtcNow.AddHours(-uow.GetTable<GuildConfig>()
                                                                          .Where(y => x.GuildId == y.GuildId)
                                                                          .Select(y => y.WarnExpireHours)
                                                                          .First()))
                               .Select(x => x.Id)
                               .ToListAsyncLinqToDB();

        var cleared = await uow.GetTable<Warning>()
                         .Where(x => toClear.Contains(x.Id))
                         .UpdateAsync(_ => new()
                         {
                             Forgiven = true,
                             ForgivenBy = "expiry"
                         });

        var toDelete = await uow.GetTable<Warning>()
                                .Where(x => uow.GetTable<GuildConfig>()
                                               .Count(y => y.GuildId == x.GuildId
                                                           && y.WarnExpireHours > 0
                                                           && y.WarnExpireAction == WarnExpireAction.Delete)
                                            > 0
                                            && x.DateAdded
                                            < DateTime.UtcNow.AddHours(-uow.GetTable<GuildConfig>()
                                                                           .Where(y => x.GuildId == y.GuildId)
                                                                           .Select(y => y.WarnExpireHours)
                                                                           .First()))
                                .Select(x => x.Id)
                                .ToListAsyncLinqToDB();

        var deleted = await uow.GetTable<Warning>()
                 .Where(x => toDelete.Contains(x.Id))
                 .DeleteAsync();

        if (cleared > 0 || deleted > 0)
        {
            Log.Information("Cleared {ClearedWarnings} warnings and deleted {DeletedWarnings} warnings due to expiry",
                cleared,
                toDelete.Count);
        }
    }

    public async Task CheckWarnExpiresAsync(ulong guildId)
    {
        await using var uow = _db.GetDbContext();
        var config = uow.GuildConfigsForId(guildId, inc => inc);

        if (config.WarnExpireHours == 0)
            return;

        if (config.WarnExpireAction == WarnExpireAction.Clear)
        {
            await uow.Set<Warning>()
                     .Where(x => x.GuildId == guildId
                                 && x.Forgiven == false
                                 && x.DateAdded < DateTime.UtcNow.AddHours(-config.WarnExpireHours))
                     .UpdateAsync(_ => new()
                     {
                         Forgiven = true,
                         ForgivenBy = "expiry"
                     });
        }
        else if (config.WarnExpireAction == WarnExpireAction.Delete)
        {
            await uow.Set<Warning>()
                     .Where(x => x.GuildId == guildId
                                 && x.DateAdded < DateTime.UtcNow.AddHours(-config.WarnExpireHours))
                     .DeleteAsync();
        }

        await uow.SaveChangesAsync();
    }

    public Task<int> GetWarnExpire(ulong guildId)
    {
        using var uow = _db.GetDbContext();
        var config = uow.GuildConfigsForId(guildId, set => set);
        return Task.FromResult(config.WarnExpireHours / 24);
    }

    public async Task WarnExpireAsync(ulong guildId, int days, bool delete)
    {
        await using (var uow = _db.GetDbContext())
        {
            var config = uow.GuildConfigsForId(guildId, inc => inc);

            config.WarnExpireHours = days * 24;
            config.WarnExpireAction = delete ? WarnExpireAction.Delete : WarnExpireAction.Clear;
            await uow.SaveChangesAsync();

            // no need to check for warn expires
            if (config.WarnExpireHours == 0)
                return;
        }

        await CheckWarnExpiresAsync(guildId);
    }

    public IGrouping<ulong, Warning>[] WarnlogAll(ulong gid)
    {
        using var uow = _db.GetDbContext();
        return uow.Set<Warning>().GetForGuild(gid).GroupBy(x => x.UserId).ToArray();
    }

    public Warning[] UserWarnings(ulong gid, ulong userId)
    {
        using var uow = _db.GetDbContext();
        return uow.Set<Warning>().ForId(gid, userId);
    }

    public async Task<bool> WarnClearAsync(
        ulong guildId,
        ulong userId,
        int index,
        string moderator)
    {
        var toReturn = true;
        await using var uow = _db.GetDbContext();
        if (index == 0)
            await uow.Set<Warning>().ForgiveAll(guildId, userId, moderator);
        else
            toReturn = uow.Set<Warning>().Forgive(guildId, userId, moderator, index - 1);
        await uow.SaveChangesAsync();
        return toReturn;
    }

    public bool WarnPunish(
        ulong guildId,
        int number,
        PunishmentAction punish,
        StoopidTime time,
        IRole role = null)
    {
        // these 3 don't make sense with time
        if (punish is PunishmentAction.Softban or PunishmentAction.Kick or PunishmentAction.RemoveRoles
            && time is not null)
            return false;
        if (number <= 0 || (time is not null && time.Time > TimeSpan.FromDays(49)))
            return false;

        if (punish is PunishmentAction.AddRole && role is null)
            return false;

        if (punish is PunishmentAction.TimeOut && time is null)
            return false;

        using var uow = _db.GetDbContext();
        var ps = uow.GuildConfigsForId(guildId, set => set.Include(x => x.WarnPunishments)).WarnPunishments;
        var toDelete = ps.Where(x => x.Count == number);

        uow.RemoveRange(toDelete);

        ps.Add(new()
        {
            Count = number,
            Punishment = punish,
            Time = (int?)time?.Time.TotalMinutes ?? 0,
            RoleId = punish == PunishmentAction.AddRole ? role!.Id : default(ulong?)
        });
        uow.SaveChanges();
        return true;
    }

    public bool WarnPunishRemove(ulong guildId, int number)
    {
        if (number <= 0)
            return false;

        using var uow = _db.GetDbContext();
        var ps = uow.GuildConfigsForId(guildId, set => set.Include(x => x.WarnPunishments)).WarnPunishments;
        var p = ps.FirstOrDefault(x => x.Count == number);

        if (p is not null)
        {
            uow.Remove(p);
            uow.SaveChanges();
        }

        return true;
    }

    public WarningPunishment[] WarnPunishList(ulong guildId)
    {
        using var uow = _db.GetDbContext();
        return uow.GuildConfigsForId(guildId, gc => gc.Include(x => x.WarnPunishments))
                  .WarnPunishments.OrderBy(x => x.Count)
                  .ToArray();
    }

    public (IReadOnlyCollection<(string Original, ulong? Id, string Reason)> Bans, int Missing) MassKill(
        SocketGuild guild,
        string people)
    {
        var gusers = guild.Users;
        //get user objects and reasons
        var bans = people.Split("\n")
                         .Select(x =>
                         {
                             var split = x.Trim().Split(" ");

                             var reason = string.Join(" ", split.Skip(1));

                             if (ulong.TryParse(split[0], out var id))
                                 return (Original: split[0], Id: id, Reason: reason);

                             return (Original: split[0],
                                 gusers.FirstOrDefault(u => u.ToString().ToLowerInvariant() == x)?.Id,
                                 Reason: reason);
                         })
                         .ToArray();

        //if user is null, means that person couldn't be found
        var missing = bans.Count(x => !x.Id.HasValue);

        //get only data for found users
        var found = bans.Where(x => x.Id.HasValue).Select(x => x.Id.Value).ToList();

        _ = _blacklistService.BlacklistUsers(found);

        return (bans, missing);
    }

    public string GetBanTemplate(ulong guildId)
    {
        using var uow = _db.GetDbContext();
        var template = uow.Set<BanTemplate>().AsQueryable().FirstOrDefault(x => x.GuildId == guildId);
        return template?.Text;
    }

    public void SetBanTemplate(ulong guildId, string text)
    {
        using var uow = _db.GetDbContext();
        var template = uow.Set<BanTemplate>().AsQueryable().FirstOrDefault(x => x.GuildId == guildId);

        if (text is null)
        {
            if (template is null)
                return;

            uow.Remove(template);
        }
        else if (template is null)
        {
            uow.Set<BanTemplate>()
               .Add(new()
               {
                   GuildId = guildId,
                   Text = text
               });
        }
        else
            template.Text = text;

        uow.SaveChanges();
    }

    public async Task SetBanPruneAsync(ulong guildId, int? pruneDays)
    {
        await using var ctx = _db.GetDbContext();
        await ctx.Set<BanTemplate>()
                 .ToLinqToDBTable()
                 .InsertOrUpdateAsync(() => new()
                 {
                     GuildId = guildId,
                     Text = null,
                     DateAdded = DateTime.UtcNow,
                     PruneDays = pruneDays
                 },
                     old => new()
                     {
                         PruneDays = pruneDays
                     },
                     () => new()
                     {
                         GuildId = guildId
                     });
    }

    public async Task<int?> GetBanPruneAsync(ulong guildId)
    {
        await using var ctx = _db.GetDbContext();
        return await ctx.Set<BanTemplate>()
                        .Where(x => x.GuildId == guildId)
                        .Select(x => x.PruneDays)
                        .FirstOrDefaultAsyncLinqToDB();
    }

    public Task<SmartText> GetBanUserDmEmbed(
        ICommandContext context,
        IGuildUser target,
        string defaultMessage,
        string banReason,
        TimeSpan? duration)
        => GetBanUserDmEmbed((DiscordSocketClient)context.Client,
            (SocketGuild)context.Guild,
            (IGuildUser)context.User,
            target,
            defaultMessage,
            banReason,
            duration);

    public async Task<SmartText> GetBanUserDmEmbed(
        DiscordSocketClient client,
        SocketGuild guild,
        IGuildUser moderator,
        IGuildUser target,
        string defaultMessage,
        string banReason,
        TimeSpan? duration)
    {
        var template = GetBanTemplate(guild.Id);

        banReason = string.IsNullOrWhiteSpace(banReason) ? "-" : banReason;

        var repCtx = new ReplacementContext(client, guild)
                     .WithOverride("%ban.mod%", () => moderator.ToString())
                     .WithOverride("%ban.mod.fullname%", () => moderator.ToString())
                     .WithOverride("%ban.mod.name%", () => moderator.Username)
                     .WithOverride("%ban.mod.discrim%", () => moderator.Discriminator)
                     .WithOverride("%ban.user%", () => target.ToString())
                     .WithOverride("%ban.user.fullname%", () => target.ToString())
                     .WithOverride("%ban.user.name%", () => target.Username)
                     .WithOverride("%ban.user.discrim%", () => target.Discriminator)
                     .WithOverride("%reason%", () => banReason)
                     .WithOverride("%ban.reason%", () => banReason)
                     .WithOverride("%ban.duration%",
                         () => duration?.ToString(@"d\.hh\:mm") ?? "perma");


        // if template isn't set, use the old message style
        if (string.IsNullOrWhiteSpace(template))
        {
            template = JsonConvert.SerializeObject(new
            {
                color = _bcs.Data.Color.Error.PackedValue >> 8,
                description = defaultMessage
            });
        }
        // if template is set to "-" do not dm the user
        else if (template == "-")
            return default;
        // if template is an embed, send that embed with replacements
        // otherwise, treat template as a regular string with replacements
        else if (SmartText.CreateFrom(template) is not { IsEmbed: true } or { IsEmbedArray: true })
        {
            template = JsonConvert.SerializeObject(new
            {
                color = _bcs.Data.Color.Error.PackedValue >> 8,
                description = template
            });
        }

        var output = SmartText.CreateFrom(template);
        return await _repSvc.ReplaceAsync(output, repCtx);
    }

    public async Task<Warning> WarnDelete(ulong userId, int index)
    {
        await using var uow = _db.GetDbContext();

        var warn = await uow.GetTable<Warning>()
                            .Where(x => x.UserId == userId)
                            .OrderByDescending(x => x.DateAdded)
                            .Skip(index)
                            .FirstOrDefaultAsyncLinqToDB();

        if (warn is not null)
        {
            await uow.GetTable<Warning>()
                     .Where(x => x.Id == warn.Id)
                     .DeleteAsync();
        }

        return warn;
    }
}