using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Data.Abstractions;
using Data.Models;
using Data.Models.Client.Stats;
using Data.Models.Client.Stats.Reference;
using Data.Models.Server;
using IW4MAdmin.Plugins.Stats.Client.Abstractions;
using IW4MAdmin.Plugins.Stats.Client.Game;
using IW4MAdmin.Plugins.Stats.Helpers;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using SharedLibraryCore;
using SharedLibraryCore.Database.Models;
using Stats.Client.Abstractions;
using Stats.Client.Game;

namespace IW4MAdmin.Plugins.Stats.Client
{
    public class HitState
    {
        public HitState()
        {
            OnTransaction = new SemaphoreSlim(1, 1);
        }

        ~HitState()
        {
            OnTransaction.Dispose();
        }

        public List<EFClientHitStatistic> Hits { get; set; }
        public DateTime? LastUsage { get; set; }
        public int? LastWeaponId { get; set; }
        public EFServer Server { get; set; }
        public SemaphoreSlim OnTransaction { get; }
        public int UpdateCount { get; set; }
    }

    public class HitCalculator : IClientStatisticCalculator
    {
        private readonly IDatabaseContextFactory _contextFactory;
        private readonly ILogger<HitCalculator> _logger;

        private readonly ConcurrentDictionary<int, HitState> _clientHitStatistics =
            new ConcurrentDictionary<int, HitState>();

        private readonly SemaphoreSlim _onTransaction = new SemaphoreSlim(1, 1);

        private readonly ILookupCache<EFServer> _serverCache;
        private readonly ILookupCache<EFHitLocation> _hitLocationCache;
        private readonly ILookupCache<EFWeapon> _weaponCache;
        private readonly ILookupCache<EFWeaponAttachment> _attachmentCache;
        private readonly ILookupCache<EFWeaponAttachmentCombo> _attachmentComboCache;
        private readonly ILookupCache<EFMeansOfDeath> _modCache;
        private readonly IHitInfoBuilder _hitInfoBuilder;
        private readonly IServerDistributionCalculator _serverDistributionCalculator;

        private readonly TimeSpan _maxActiveTime = TimeSpan.FromMinutes(2);
        private const int MaxUpdatesBeforePersist = 20;
        private const string SessionScores = nameof(SessionScores);

        public HitCalculator(ILogger<HitCalculator> logger, IDatabaseContextFactory contextFactory,
            ILookupCache<EFHitLocation> hitLocationCache, ILookupCache<EFWeapon> weaponCache,
            ILookupCache<EFWeaponAttachment> attachmentCache,
            ILookupCache<EFWeaponAttachmentCombo> attachmentComboCache,
            ILookupCache<EFServer> serverCache, ILookupCache<EFMeansOfDeath> modCache, IHitInfoBuilder hitInfoBuilder,
            IServerDistributionCalculator serverDistributionCalculator)
        {
            _contextFactory = contextFactory;
            _logger = logger;
            _hitLocationCache = hitLocationCache;
            _weaponCache = weaponCache;
            _attachmentCache = attachmentCache;
            _attachmentComboCache = attachmentComboCache;
            _serverCache = serverCache;
            _hitInfoBuilder = hitInfoBuilder;
            _modCache = modCache;
            _serverDistributionCalculator = serverDistributionCalculator;
        }

        public async Task GatherDependencies()
        {
            await _hitLocationCache.InitializeAsync();
            await _weaponCache.InitializeAsync();
            await _attachmentCache.InitializeAsync();
            await _attachmentComboCache.InitializeAsync();
            await _serverCache.InitializeAsync();
            await _modCache.InitializeAsync();
        }

        public async Task CalculateForEvent(GameEvent gameEvent)
        {
            if (gameEvent.Type == GameEvent.EventType.Connect)
            {
                // if no servers have been cached yet we need to pull them here
                // as they could have gotten added after we've initialized
                if (!_serverCache.GetAll().Any())
                {
                    await _serverCache.InitializeAsync();
                }

                gameEvent.Origin.SetAdditionalProperty(SessionScores, new List<(int, DateTime)>());
                return;
            }

            if (gameEvent.Type == GameEvent.EventType.Disconnect)
            {
                _clientHitStatistics.Remove(gameEvent.Origin.ClientId, out var state);

                if (state == null)
                {
                    _logger.LogWarning("No client hit state available for disconnecting client {client}",
                        gameEvent.Origin.ToString());
                    return;
                }

                try
                {
                    await state.OnTransaction.WaitAsync();
                    HandleDisconnectCalculations(gameEvent.Origin, state);
                    await UpdateClientStatistics(gameEvent.Origin.ClientId, state);
                }

                catch (Exception ex)
                {
                    _logger.LogError(ex, "Could not handle disconnect calculations for client {client}",
                        gameEvent.Origin.ToString());
                }

                finally
                {
                    if (state.OnTransaction.CurrentCount == 0)
                    {
                        state.OnTransaction.Release();
                    }
                }

                return;
            }

            if (gameEvent.Type == GameEvent.EventType.MapEnd)
            {
                foreach (var client in gameEvent.Owner.GetClientsAsList())
                {
                    var scores = client.GetAdditionalProperty<List<(int, DateTime)>>(SessionScores);
                    scores?.Add((client.GetAdditionalProperty<int?>(StatManager.ESTIMATED_SCORE) ?? client.Score, DateTime.Now));
                }
            }

            if (gameEvent.Type != GameEvent.EventType.Kill && gameEvent.Type != GameEvent.EventType.Damage)
            {
                return;
            }

            var eventRegex = gameEvent.Type == GameEvent.EventType.Kill
                ? gameEvent.Owner.EventParser.Configuration.Kill
                : gameEvent.Owner.EventParser.Configuration.Damage;

            var match = eventRegex.PatternMatcher.Match(gameEvent.Data);

            if (!match.Success)
            {
                _logger.LogWarning("Log for event type {type} does not match pattern {logLine}", gameEvent.Type,
                    gameEvent.Data);
                return;
            }

            var attackerHitInfo = _hitInfoBuilder.Build(match.Values.ToArray(), eventRegex, gameEvent.Origin.ClientId,
                gameEvent.Origin.ClientId == gameEvent.Target.ClientId, false, gameEvent.Owner.GameName);
            var victimHitInfo = _hitInfoBuilder.Build(match.Values.ToArray(), eventRegex, gameEvent.Target.ClientId,
                gameEvent.Origin.ClientId == gameEvent.Target.ClientId, true, gameEvent.Owner.GameName);

            foreach (var hitInfo in new[] {attackerHitInfo, victimHitInfo})
            {
                if (hitInfo.MeansOfDeath == null || hitInfo.Location == null || hitInfo.Weapon == null)
                {
                    _logger.LogDebug("Skipping hit because it does not contain the required data");
                    continue;
                }
                
                try
                {
                    await _onTransaction.WaitAsync();
                    if (!_clientHitStatistics.ContainsKey(hitInfo.EntityId))
                    {
                        _logger.LogDebug("Starting to track hits for {client}", hitInfo.EntityId);
                        var clientHits = await GetHitsForClient(hitInfo.EntityId);
                        _clientHitStatistics.TryAdd(hitInfo.EntityId, new HitState()
                        {
                            Hits = clientHits,
                            Server = (await _serverCache
                                .FirstAsync(server =>
                                    server.EndPoint == gameEvent.Owner.ToString() && server.HostName != null))
                        });
                    }
                }
                catch (Exception ex)
                {
                    _logger.LogError(ex, "Could not retrieve previous hit data for client {Client}", hitInfo.EntityId);
                    continue;
                }

                finally
                {
                    if (_onTransaction.CurrentCount == 0)
                    {
                        _onTransaction.Release();
                    }
                }

                var state = _clientHitStatistics[hitInfo.EntityId];

                try
                {
                    await _onTransaction.WaitAsync();
                    var calculatedHits = await RunTasksForHitInfo(hitInfo, state.Server.ServerId);

                    foreach (var clientHit in calculatedHits)
                    {
                        RunCalculation(clientHit, hitInfo, state);
                    }
                }

                catch (Exception ex)
                {
                    _logger.LogError(ex, "Could not update hit calculations for {client}", hitInfo.EntityId);
                }

                finally
                {
                    if (_onTransaction.CurrentCount == 0)
                    {
                        _onTransaction.Release();
                    }
                }
            }
        }

        private async Task<IEnumerable<EFClientHitStatistic>> RunTasksForHitInfo(HitInfo hitInfo, long? serverId)
        {
            var weapon = await GetOrAddWeapon(hitInfo.Weapon, hitInfo.Game);
            var attachments =
                await Task.WhenAll(hitInfo.Weapon.Attachments.Select(attachment =>
                    GetOrAddAttachment(attachment, hitInfo.Game)));
            var attachmentCombo = await GetOrAddAttachmentCombo(attachments, hitInfo.Game);
            var matchingLocation = await GetOrAddHitLocation(hitInfo.Location, hitInfo.Game);
            var meansOfDeath = await GetOrAddMeansOfDeath(hitInfo.MeansOfDeath, hitInfo.Game);

            var baseTasks = new[]
            {
                // just the client
                GetOrAddClientHit(hitInfo.EntityId, null),
                // client and server
                GetOrAddClientHit(hitInfo.EntityId, serverId),
                // just the location
                GetOrAddClientHit(hitInfo.EntityId, null, matchingLocation.HitLocationId),
                // location and server
                GetOrAddClientHit(hitInfo.EntityId, serverId, matchingLocation.HitLocationId),
                // per weapon
                GetOrAddClientHit(hitInfo.EntityId, null, null, weapon.WeaponId),
                // per weapon and server
                GetOrAddClientHit(hitInfo.EntityId, serverId, null, weapon.WeaponId),
                // means of death aggregate
                GetOrAddClientHit(hitInfo.EntityId, meansOfDeathId: meansOfDeath.MeansOfDeathId),
                // means of death per server aggregate
                GetOrAddClientHit(hitInfo.EntityId, serverId,
                    meansOfDeathId: meansOfDeath.MeansOfDeathId)
            };

            var allTasks = baseTasks.AsEnumerable();

            if (attachmentCombo != null)
            {
                allTasks = allTasks
                    // per weapon per attachment combo
                    .Append(GetOrAddClientHit(hitInfo.EntityId, null, null,
                        weapon.WeaponId, attachmentCombo.WeaponAttachmentComboId))
                    .Append(GetOrAddClientHit(hitInfo.EntityId, serverId, null,
                        weapon.WeaponId, attachmentCombo.WeaponAttachmentComboId));
            }

            return await Task.WhenAll(allTasks);
        }

        private void RunCalculation(EFClientHitStatistic clientHit, HitInfo hitInfo, HitState hitState)
        {
            if (hitInfo.HitType == HitType.Kill || hitInfo.HitType == HitType.Damage)
            {
                if (clientHit.WeaponId != null) // we only want to calculate usage time for weapons
                {
                    var timeElapsed = DateTime.Now - hitState.LastUsage;
                    var isSameWeapon = clientHit.WeaponId == hitState.LastWeaponId;

                    clientHit.UsageSeconds ??= 60;

                    if (timeElapsed.HasValue && timeElapsed <= _maxActiveTime)
                    {
                        clientHit.UsageSeconds
                            += // if it's the same weapon we can count the entire elapsed time
                            // otherwise we split it to make a best guess
                            (int) Math.Round(timeElapsed.Value.TotalSeconds / (isSameWeapon ? 1.0 : 2.0));
                    }

                    hitState.LastUsage = DateTime.Now;
                }

                clientHit.DamageInflicted += hitInfo.Damage;
                clientHit.HitCount++;
            }

            if (hitInfo.HitType == HitType.Kill)
            {
                clientHit.KillCount++;
            }

            if (hitInfo.HitType == HitType.WasKilled || hitInfo.HitType == HitType.WasDamaged ||
                hitInfo.HitType == HitType.Suicide)
            {
                clientHit.ReceivedHitCount++;
                clientHit.DamageReceived += hitInfo.Damage;
            }

            if (hitInfo.HitType == HitType.WasKilled)
            {
                clientHit.DeathCount++;
            }
        }

        private async Task<List<EFClientHitStatistic>> GetHitsForClient(int clientId)
        {
            try
            {
                await using var context = _contextFactory.CreateContext();
                var hitLocations = await context.Set<EFClientHitStatistic>()
                    .Where(stat => stat.ClientId == clientId)
                    .ToListAsync();

                return !hitLocations.Any() ? new List<EFClientHitStatistic>() : hitLocations;
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Could not retrieve {hitName} for client with id {id}",
                    nameof(EFClientHitStatistic), clientId);
            }

            return new List<EFClientHitStatistic>();
        }

        private async Task UpdateClientStatistics(int clientId, HitState locState = null)
        {
            if (!_clientHitStatistics.ContainsKey(clientId) && locState == null)
            {
                _logger.LogError("No {statsName} found for id {id}", nameof(EFClientHitStatistic), clientId);
                return;
            }

            var state = locState ?? _clientHitStatistics[clientId];

            try
            {
                await using var context = _contextFactory.CreateContext();
                context.Set<EFClientHitStatistic>().UpdateRange(state.Hits);
                await context.SaveChangesAsync();
            }

            catch (Exception ex)
            {
                _logger.LogError(ex, "Could not update hit location stats for id {id}", clientId);
            }
        }

        private async Task<EFClientHitStatistic> GetOrAddClientHit(int clientId, long? serverId = null,
            int? hitLocationId = null, int? weaponId = null, int? attachmentComboId = null,
            int? meansOfDeathId = null)
        {
            var state = _clientHitStatistics[clientId];
            await state.OnTransaction.WaitAsync();

            var hitStat = state.Hits
                .FirstOrDefault(hit => hit.HitLocationId == hitLocationId
                                       && hit.WeaponId == weaponId
                                       && hit.WeaponAttachmentComboId == attachmentComboId
                                       && hit.MeansOfDeathId == meansOfDeathId
                                       && hit.ServerId == serverId);

            if (hitStat != null)
            {
                state.OnTransaction.Release();
                return hitStat;
            }

            hitStat = new EFClientHitStatistic()
            {
                ClientId = clientId,
                ServerId = serverId,
                WeaponId = weaponId,
                WeaponAttachmentComboId = attachmentComboId,
                HitLocationId = hitLocationId,
                MeansOfDeathId = meansOfDeathId
            };

            try
            {
                /*if (state.UpdateCount > MaxUpdatesBeforePersist)
                {
                    await UpdateClientStatistics(clientId);
                    state.UpdateCount = 0;
                }

                state.UpdateCount++;*/
                state.Hits.Add(hitStat);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Could not add {statsName} for {id}", nameof(EFClientHitStatistic),
                    clientId);
                state.Hits.Remove(hitStat);
            }
            finally
            {
                if (state.OnTransaction.CurrentCount == 0)
                {
                    state.OnTransaction.Release();
                }
            }

            return hitStat;
        }

        private async Task<EFHitLocation> GetOrAddHitLocation(string location, Reference.Game game)
        {
            var matchingLocation = (await _hitLocationCache
                .FirstAsync(loc => loc.Name == location && loc.Game == game));

            if (matchingLocation != null)
            {
                return matchingLocation;
            }

            var hitLocation = new EFHitLocation()
            {
                Name = location,
                Game = game
            };

            hitLocation = await _hitLocationCache.AddAsync(hitLocation);

            return hitLocation;
        }

        private async Task<EFWeapon> GetOrAddWeapon(WeaponInfo weapon, Reference.Game game)
        {
            var matchingWeapon = (await _weaponCache
                .FirstAsync(wep => wep.Name == weapon.Name && wep.Game == game));

            if (matchingWeapon != null)
            {
                return matchingWeapon;
            }

            matchingWeapon = new EFWeapon()
            {
                Name = weapon.Name,
                Game = game
            };

            matchingWeapon = await _weaponCache.AddAsync(matchingWeapon);

            return matchingWeapon;
        }

        private async Task<EFWeaponAttachment> GetOrAddAttachment(AttachmentInfo attachment, Reference.Game game)
        {
            var matchingAttachment = (await _attachmentCache
                .FirstAsync(attach => attach.Name == attachment.Name && attach.Game == game));

            if (matchingAttachment != null)
            {
                return matchingAttachment;
            }

            matchingAttachment = new EFWeaponAttachment()
            {
                Name = attachment.Name,
                Game = game
            };

            matchingAttachment = await _attachmentCache.AddAsync(matchingAttachment);

            return matchingAttachment;
        }

        private async Task<EFWeaponAttachmentCombo> GetOrAddAttachmentCombo(EFWeaponAttachment[] attachments,
            Reference.Game game)
        {
            if (!attachments.Any())
            {
                return null;
            }

            var allAttachments = attachments.ToList();

            if (allAttachments.Count() < 3)
            {
                for (var i = allAttachments.Count(); i <= 3; i++)
                {
                    allAttachments.Add(null);
                }
            }

            var matchingAttachmentCombo = (await _attachmentComboCache.FirstAsync(combo =>
                combo.Game == game
                && combo.Attachment1Id == allAttachments[0].Id
                && combo.Attachment2Id == allAttachments[1]?.Id
                && combo.Attachment3Id == allAttachments[2]?.Id));

            if (matchingAttachmentCombo != null)
            {
                return matchingAttachmentCombo;
            }

            matchingAttachmentCombo = new EFWeaponAttachmentCombo()
            {
                Game = game,
                Attachment1Id = (int) allAttachments[0].Id,
                Attachment2Id = (int?) allAttachments[1]?.Id,
                Attachment3Id = (int?) allAttachments[2]?.Id,
            };

            matchingAttachmentCombo = await _attachmentComboCache.AddAsync(matchingAttachmentCombo);

            return matchingAttachmentCombo;
        }

        private async Task<EFMeansOfDeath> GetOrAddMeansOfDeath(string meansOfDeath, Reference.Game game)
        {
            var matchingMod = (await _modCache
                .FirstAsync(mod => mod.Name == meansOfDeath && mod.Game == game));

            if (matchingMod != null)
            {
                return matchingMod;
            }

            var mod = new EFMeansOfDeath()
            {
                Name = meansOfDeath,
                Game = game
            };

            mod = await _modCache.AddAsync(mod);

            return mod;
        }

        private void HandleDisconnectCalculations(EFClient client, HitState state)
        {
            // todo: this not added to states fast connect/disconnect
            var serverStats = state.Hits.FirstOrDefault(stat =>
                stat.ServerId == state.Server.ServerId && stat.WeaponId == null &&
                stat.WeaponAttachmentComboId == null && stat.HitLocationId == null && stat.MeansOfDeathId == null);

            if (serverStats == null)
            {
                _logger.LogWarning("No server hits were found for {serverId} on disconnect for {client}",
                    state.Server.ServerId, client.ToString());
                return;
            }

            var aggregate = state.Hits.FirstOrDefault(stat => stat.WeaponId == null &&
                                                              stat.WeaponAttachmentComboId == null &&
                                                              stat.HitLocationId == null &&
                                                              stat.MeansOfDeathId == null &&
                                                              stat.ServerId == null);

            if (aggregate == null)
            {
                _logger.LogWarning("No aggregate found for {serverId} on disconnect for {client}",
                    state.Server.ServerId, client.ToString());
                return;
            }

            var sessionScores = client.GetAdditionalProperty<List<(int, DateTime)>>(SessionScores);

            if (sessionScores == null)
            {
                _logger.LogWarning("No session scores available for {Client}", client.ToString());
                return;
            }

            foreach (var stat in new[] {serverStats, aggregate})
            {
                stat.Score ??= 0;

                if (sessionScores.Count == 0)
                {
                    stat.Score += client.Score > 0 ? client.Score : client.GetAdditionalProperty<int?>(Helpers.StatManager.ESTIMATED_SCORE) ?? 0 * 50;
                }

                else
                {
                    stat.Score += sessionScores.Sum(item => item.Item1) +
                                  (sessionScores.Last().Item1 == client.Score &&
                                   (DateTime.Now - sessionScores.Last().Item2).TotalMinutes < 1
                                      ? 0
                                      : client.Score);
                }
            }
        }
    }
}