using System.Security.Cryptography;
using System.Text;
using AngleSharp.Common;
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using EllieBot.Modules.Administration;
using EllieBot.Modules.Administration.Services;
using EllieBot.Modules.Games.Quests;

namespace EllieBot.Modules.Games.Fish;

public sealed class FishService(
    FishConfigService fcs,
    IBotCache cache,
    DbService db,
    INotifySubscriber notify,
    QuestService quests,
    FishItemService itemService
)
    : IEService
{
    private const double MAX_SKILL = 100;

    private readonly Random _rng = new Random();

    private static TypedKey<bool> FishingKey(ulong userId)
        => new($"fishing:{userId}");

    public async Task<OneOf.OneOf<Task<FishResult?>, AlreadyFishing>> FishAsync(ulong userId, ulong channelId,
        FishMultipliers multipliers)
    {
        var duration = _rng.Next(3, 6) / multipliers.FishingSpeedMultiplier;

        if (!await cache.AddAsync(FishingKey(userId), true, TimeSpan.FromSeconds(duration), overwrite: false))
        {
            return new AlreadyFishing();
        }

        return TryFishAsync(userId, channelId, duration, multipliers);
    }

    private async Task<FishResult?> TryFishAsync(
        ulong userId,
        ulong channelId,
        double duration,
        FishMultipliers multipliers)
    {
        var conf = fcs.Data;
        await Task.Delay(TimeSpan.FromSeconds(duration));

        var (playerSkill, _) = await GetSkill(userId);
        var fishChanceMultiplier = Math.Clamp((playerSkill + 20) / MAX_SKILL, 0, 1);
        var trashChanceMultiplier = Math.Clamp(((2 * MAX_SKILL) - playerSkill) / MAX_SKILL, 1, 2);

        var nothingChance = conf.Chance.Nothing;
        var fishChance = conf.Chance.Fish * fishChanceMultiplier * multipliers.FishMultiplier;
        var trashChance = conf.Chance.Trash * trashChanceMultiplier * multipliers.TrashMultiplier;

        // first roll whether it's fish, trash or nothing
        var totalChance = fishChance + trashChance + conf.Chance.Nothing;

        var typeRoll = _rng.NextDouble() * totalChance;

        if (typeRoll < nothingChance)
        {
            return null;
        }

        var isFish = typeRoll < nothingChance + fishChance;

        var items = isFish
            ? conf.Fish
            : conf.Trash;

        var result = await FishAsyncInternal(userId, channelId, items, multipliers);
        
        // use bait
        if (result is not null)
        {
            await itemService.UseBaitAsync(userId);
        }

        // skill
        if (result is not null)
        {
            var isSkillUp = await TrySkillUpAsync(userId, playerSkill);

            result.IsSkillUp = isSkillUp;
            result.MaxSkill = (int)MAX_SKILL;
            result.Skill = playerSkill;

            if (isSkillUp)
            {
                result.Skill += 1;
            }
        }

        // notification system
        if (result is not null)
        {
            if (result.IsMaxStar() || result.IsRare())
            {
                await notify.NotifyAsync(new NiceCatchNotifyModel(
                    userId,
                    result.Fish,
                    GetStarText(result.Stars, result.Fish.Stars)
                ));
            }

            await quests.ReportActionAsync(userId,
                QuestEventType.FishCaught,
                new()
                {
                    { "fish", result.Fish.Name },
                    { "type", typeRoll < nothingChance + fishChance ? "fish" : "trash" },
                    { "stars", result.Stars.ToString() }
                });
        }

        return result;
    }

    private async Task<bool> TrySkillUpAsync(ulong userId, int playerSkill)
    {
        var skillUpProb = GetSkillUpProb(playerSkill);

        var rng = _rng.NextDouble();

        if (rng < skillUpProb)
        {
            await using var ctx = db.GetDbContext();

            var maxSkill = (int)MAX_SKILL;
            await ctx.GetTable<UserFishStats>()
                .InsertOrUpdateAsync(() => new()
                    {
                        UserId = userId,
                        Skill = 1,
                    },
                    (old) => new()
                    {
                        UserId = userId,
                        Skill = old.Skill > maxSkill ? maxSkill : old.Skill + 1
                    },
                    () => new()
                    {
                        UserId = userId,
                        Skill = playerSkill
                    });

            return true;
        }

        return false;
    }

    private double GetSkillUpProb(int playerSkill)
    {
        if (playerSkill < 0)
            playerSkill = 0;

        if (playerSkill >= 100)
            return 0;

        return 1 / (Math.Pow(Math.E, playerSkill / 22d));
    }

    public async Task<(int skill, int maxSkill)> GetSkill(ulong userId)
    {
        await using var ctx = db.GetDbContext();

        var skill = await ctx.GetTable<UserFishStats>()
            .Where(x => x.UserId == userId)
            .Select(x => x.Skill)
            .FirstOrDefaultAsyncLinqToDB();

        return (skill, (int)MAX_SKILL);
    }

    private async Task<FishResult?> FishAsyncInternal(
        ulong userId,
        ulong channelId,
        List<FishData> items,
        FishMultipliers multipliers)
    {
        var filteredItems = new List<FishData>();

        var loc = GetSpot(channelId);
        var time = GetTime();
        var w = GetWeather(DateTime.UtcNow);

        foreach (var item in items)
        {
            if (item.Condition is { Count: > 0 })
            {
                if (!item.Condition.Any(x => channelId.ToString().EndsWith(x)))
                {
                    continue;
                }
            }

            if (item.Spot is not null && item.Spot != loc)
                continue;

            if (item.Time is not null && item.Time != time)
                continue;

            if (item.Weather is not null && item.Weather != w)
                continue;

            filteredItems.Add(item);
        }


        var maxSum = filteredItems
            .Select(x => (x.Id, x.Chance, x.Stars))
            .Select(x =>
            {
                if (x.Chance <= 15)
                    return x with
                    {
                        Chance = x.Chance *= multipliers.RareMultiplier
                    };

                return x;
            })
            .Sum(x => { return x.Chance * 100; });


        var roll = _rng.NextDouble() * maxSum;

        FishResult? caught = null;

        var curSum = 0d;
        foreach (var i in filteredItems)
        {
            curSum += i.Chance * 100;

            if (roll < curSum)
            {
                caught = new FishResult()
                {
                    Fish = i,
                    Stars = GetRandomStars(i.Stars, multipliers),
                };
                break;
            }
        }

        if (caught is not null)
        {
            await using var uow = db.GetDbContext();

            await uow.GetTable<FishCatch>()
                .InsertOrUpdateAsync(() => new FishCatch()
                    {
                        UserId = userId,
                        FishId = caught.Fish.Id,
                        MaxStars = caught.Stars,
                        Count = 1
                    },
                    (old) => new FishCatch()
                    {
                        Count = old.Count + 1,
                        MaxStars = Math.Max(old.MaxStars, caught.Stars),
                    },
                    () => new()
                    {
                        FishId = caught.Fish.Id,
                        UserId = userId
                    });

            return caught;
        }

        Log.Error(
            "Something went wrong in the fish command, no fish with sufficient chance was found, Roll: {Roll}, MaxSum: {MaxSum}",
            roll,
            maxSum);

        return null;
    }

    public FishingSpot GetSpot(ulong channelId)
    {
        var cid = (channelId >> 22 >> 29) % 10;

        return cid switch
        {
            < 1 => FishingSpot.Reef,
            < 3 => FishingSpot.River,
            < 5 => FishingSpot.Lake,
            < 7 => FishingSpot.Swamp,
            _ => FishingSpot.Ocean,
        };
    }

    public FishingTime GetTime()
    {
        var hour = DateTime.UtcNow.Hour % 12;

        if (hour < 3)
            return FishingTime.Night;

        if (hour < 4)
            return FishingTime.Dawn;

        if (hour < 11)
            return FishingTime.Day;

        return FishingTime.Dusk;
    }

    private const int WEATHER_PERIODS_PER_DAY = 12;

    public IReadOnlyList<FishingWeather> GetWeatherForPeriods(int periods)
    {
        var now = DateTime.UtcNow;
        var result = new FishingWeather[periods];

        for (var i = 0; i < periods; i++)
        {
            result[i] = GetWeather(now.AddHours(i * GetWeatherPeriodDuration()));
        }

        return result;
    }

    public FishingWeather GetCurrentWeather()
        => GetWeather(DateTime.UtcNow);

    public FishingWeather GetWeather(DateTime time)
        => GetWeather(time, fcs.Data.WeatherSeed);

    private FishingWeather GetWeather(DateTime time, string seed)
    {
        var year = time.Year;
        var dayOfYear = time.DayOfYear;
        var hour = time.Hour;

        var num = (year * 100_000) + (dayOfYear * 100) + (hour / GetWeatherPeriodDuration());

        Span<byte> dataArray = stackalloc byte[4];
        BitConverter.TryWriteBytes(dataArray, num);

        Span<byte> seedArray = stackalloc byte[seed.Length];
        for (var index = 0; index < seed.Length; index++)
        {
            var c = seed[index];
            seedArray[index] = (byte)c;
        }

        Span<byte> arr = stackalloc byte[dataArray.Length + seedArray.Length];

        dataArray.CopyTo(arr);
        seedArray.CopyTo(arr[dataArray.Length..]);

        using var algo = SHA512.Create();

        Span<byte> hash = stackalloc byte[64];
        algo.TryComputeHash(arr, hash, out _);

        byte reduced = 0;
        foreach (var u in hash)
            reduced ^= u;

        var r = reduced % 16;

        // return (FishingWeather)r;
        return r switch
        {
            < 5 => FishingWeather.Clear,
            < 9 => FishingWeather.Rain,
            < 13 => FishingWeather.Storm,
            _ => FishingWeather.Snow
        };
    }


    /// <summary>
    /// Returns a random number of stars between 1 and maxStars
    /// if maxStars == 1, returns 1
    /// if maxStars == 2, returns 1 (66%) or 2 (33%)
    /// if maxStars == 3, returns 1 (65%) or 2 (25%) or 3 (10%)
    /// if maxStars == 5, returns 1 (40%) or 2 (30%) or 3 (15%) or 4 (10%) or 5 (5%)
    /// </summary>
    /// <param name="maxStars">Max Number of stars to generate</param>
    /// <param name="multipliers"></param>
    /// <returns>Random number of stars</returns>
    private int GetRandomStars(int maxStars, FishMultipliers multipliers)
    {
        if (maxStars == 1)
            return 1;

        var maxStarMulti = multipliers.StarMultiplier;
        double baseChance;
        if (maxStars == 2)
        {
            // 15% chance of 1 star, 85% chance of 2 stars
            baseChance = Math.Clamp(0.15 * multipliers.StarMultiplier, 0, 1);
            return _rng.NextDouble() < (1 - baseChance) ? 1 : 2;
        }

        if (maxStars == 3)
        {
            // 65% chance of 1 star, 30% chance of 2 stars, 5% chance of 3 stars
            baseChance = 0.05 * multipliers.StarMultiplier;
            var r = _rng.NextDouble();
            if (r < (1 - baseChance - 0.3))
                return 1;
            if (r < (1 - baseChance))
                return 2;
            return 3;
        }

        if (maxStars == 4)
        {
            // this should never happen
            // 50% chance of 1 star, 25% chance of 2 stars, 18% chance of 3 stars, 7% chance of 4 stars
            var r = _rng.NextDouble();
            baseChance = 0.02 * multipliers.StarMultiplier;
            if (r < (1 - baseChance - 0.45))
                return 1;
            if (r < (1 - baseChance - 0.15))
                return 2;
            if (r < (1 - baseChance))
                return 3;
            return 4;
        }

        if (maxStars == 5)
        {
            // 40% chance of 1 star, 30% chance of 2 stars, 15% chance of 3 stars, 10% chance of 4 stars, 2% chance of 5 stars
            var r = _rng.NextDouble();
            baseChance = 0.02 * multipliers.StarMultiplier;
            if (r < (1 - baseChance - 0.6))
                return 1;
            if (r < (1 - baseChance - 0.3))
                return 2;
            if (r < (1 - baseChance - 0.1))
                return 3;
            if (r < (1 - baseChance))
                return 4;
            return 5;
        }

        return 1;
    }

    public int GetWeatherPeriodDuration()
        => 24 / WEATHER_PERIODS_PER_DAY;

    public async Task<List<FishData>> GetAllFish()
    {
        await Task.Yield();

        var conf = fcs.Data;
        return conf.Fish.Concat(conf.Trash).ToList();
    }

    public async Task<List<FishCatch>> GetUserCatches(ulong userId)
    {
        await using var ctx = db.GetDbContext();

        var catches = await ctx.GetTable<FishCatch>()
            .Where(x => x.UserId == userId)
            .ToListAsyncLinqToDB();

        return catches;
    }

    public async Task<IReadOnlyCollection<(ulong UserId, int Catches, int Unique)>> GetFishLbAsync(int page)
    {
        await using var ctx = db.GetDbContext();

        var result = await ctx.GetTable<FishCatch>()
            .GroupBy(x => x.UserId)
            .OrderByDescending(x => x.Count()).ThenByDescending(x => x.Sum(x => x.Count))
            .Skip(page * 10)
            .Take(10)
            .Select(x => new
            {
                UserId = x.Key,
                Catches = x.Sum(x => x.Count),
                Unique = x.Count()
            })
            .ToListAsyncLinqToDB()
            .Fmap(x => x.Map(y => (y.UserId, y.Catches, y.Unique)).ToList());

        return result;
    }

    public string GetStarText(int resStars, int fishStars)
    {
        if (resStars == fishStars)
        {
            return MultiplyStars(fcs.Data.StarEmojis[^1], fishStars);
        }

        var c = fcs.Data;
        var starsp1 = MultiplyStars(c.StarEmojis[resStars], resStars);
        var starsp2 = MultiplyStars(c.StarEmojis[0], fishStars - resStars);

        return starsp1 + starsp2;
    }

    private string MultiplyStars(string starEmoji, int count)
    {
        var sb = new StringBuilder();

        for (var i = 0; i < count; i++)
        {
            sb.Append(starEmoji);
        }

        return sb.ToString();
    }
}

public sealed class IUserFishCatch
{
    public ulong UserId { get; set; }
    public int Count { get; set; }
}