using FFXIVClientStructs.FFXIV.Client.Game.Character;
using LightlessSync.API.Data.Enum;
using LightlessSync.FileCache;
using LightlessSync.Interop.Ipc;
using LightlessSync.LightlessConfiguration;
using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.PlayerData.Data;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Diagnostics;

namespace LightlessSync.PlayerData.Factories;

public class PlayerDataFactory
{
    private readonly DalamudUtilService _dalamudUtil;
    private readonly FileCacheManager _fileCacheManager;
    private readonly IpcManager _ipcManager;
    private readonly ILogger<PlayerDataFactory> _logger;
    private readonly PerformanceCollectorService _performanceCollector;
    private readonly LightlessConfigService _configService;
    private readonly XivDataAnalyzer _modelAnalyzer;
    private readonly LightlessMediator _lightlessMediator;
    private readonly TransientResourceManager _transientResourceManager;
    private static readonly SemaphoreSlim _papParseLimiter = new(1, 1);

    // Transient resolved entries threshold
    private const int _maxTransientResolvedEntries = 1000;

    // Character build caches
    private readonly ConcurrentDictionary<nint, Task<CharacterDataFragment>> _characterBuildInflight = new();
    private readonly ConcurrentDictionary<nint, CacheEntry> _characterBuildCache = new();

    // Time out thresholds
    private static readonly TimeSpan _characterCacheTtl = TimeSpan.FromMilliseconds(750);
    private static readonly TimeSpan _softReturnIfBusyAfter = TimeSpan.FromMilliseconds(250);
    private static readonly TimeSpan _hardBuildTimeout = TimeSpan.FromSeconds(30);

    public PlayerDataFactory(
        ILogger<PlayerDataFactory> logger,
        DalamudUtilService dalamudUtil,
        IpcManager ipcManager,
        TransientResourceManager transientResourceManager,
        FileCacheManager fileReplacementFactory,
        PerformanceCollectorService performanceCollector,
        XivDataAnalyzer modelAnalyzer,
        LightlessMediator lightlessMediator,
        LightlessConfigService configService)
    {
        _logger = logger;
        _dalamudUtil = dalamudUtil;
        _ipcManager = ipcManager;
        _transientResourceManager = transientResourceManager;
        _fileCacheManager = fileReplacementFactory;
        _performanceCollector = performanceCollector;
        _modelAnalyzer = modelAnalyzer;
        _lightlessMediator = lightlessMediator;
        _configService = configService;
        _logger.LogTrace("Creating {this}", nameof(PlayerDataFactory));
    }
    private sealed record CacheEntry(CharacterDataFragment Fragment, DateTime CreatedUtc);

    public async Task<CharacterDataFragment?> BuildCharacterData(GameObjectHandler playerRelatedObject, CancellationToken token)
    {
        if (!_ipcManager.Initialized)
            throw new InvalidOperationException("Penumbra or Glamourer is not connected");

        if (playerRelatedObject == null) return null;

        bool pointerIsZero = true;
        try
        {
            pointerIsZero = playerRelatedObject.Address == IntPtr.Zero;
            try
            {
                pointerIsZero = await CheckForNullDrawObject(playerRelatedObject.Address).ConfigureAwait(false);
            }
            catch
            {
                pointerIsZero = true;
                _logger.LogDebug("NullRef for {object}", playerRelatedObject);
            }
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Could not create data for {object}", playerRelatedObject);
        }

        if (pointerIsZero)
        {
            _logger.LogTrace("Pointer was zero for {objectKind}; couldn't build character", playerRelatedObject.ObjectKind);
            return null;
        }

        try
        {
            return await _performanceCollector.LogPerformance(
                this,
                $"CreateCharacterData>{playerRelatedObject.ObjectKind}",
                async () => await CreateCharacterData(playerRelatedObject, token).ConfigureAwait(false)
            ).ConfigureAwait(false);
        }
        catch (OperationCanceledException)
        {
            _logger.LogDebug("Cancelled creating Character data for {object}", playerRelatedObject);
            throw;
        }
        catch (Exception e)
        {
            _logger.LogWarning(e, "Failed to create {object} data", playerRelatedObject);
        }

        return null;
    }

    private async Task<bool> CheckForNullDrawObject(IntPtr playerPointer)
        => await _dalamudUtil.RunOnFrameworkThread(() => CheckForNullDrawObjectUnsafe(playerPointer)).ConfigureAwait(false);

    private unsafe static bool CheckForNullDrawObjectUnsafe(IntPtr playerPointer)
    {
        if (playerPointer == IntPtr.Zero)
            return true;

        var character = (Character*)playerPointer;
        if (character == null)
            return true;

        var gameObject = &character->GameObject;
        if (gameObject == null)
            return true;

        return gameObject->DrawObject == null;
    }

    private static bool IsCacheFresh(CacheEntry entry)
        => (DateTime.UtcNow - entry.CreatedUtc) <= _characterCacheTtl;

    private Task<CharacterDataFragment> CreateCharacterData(GameObjectHandler playerRelatedObject, CancellationToken ct)
        => CreateCharacterDataCoalesced(playerRelatedObject, ct);

    private async Task<CharacterDataFragment> CreateCharacterDataCoalesced(GameObjectHandler obj, CancellationToken ct)
    {
        var key = obj.Address;

        if (_characterBuildCache.TryGetValue(key, out var cached) && IsCacheFresh(cached) && !_characterBuildInflight.ContainsKey(key))
            return cached.Fragment;

        var buildTask = _characterBuildInflight.GetOrAdd(key, _ => BuildAndCacheAsync(obj, key));

        if (_characterBuildCache.TryGetValue(key, out cached))
        {
            var completed = await Task.WhenAny(buildTask, Task.Delay(_softReturnIfBusyAfter, ct)).ConfigureAwait(false);
            if (completed != buildTask && (DateTime.UtcNow - cached.CreatedUtc) <= TimeSpan.FromSeconds(5))
            {
                return cached.Fragment;
            }
        }

        return await WithCancellation(buildTask, ct).ConfigureAwait(false);
    }

    private async Task<CharacterDataFragment> BuildAndCacheAsync(GameObjectHandler obj, nint key)
    {
        try
        {
            using var cts = new CancellationTokenSource(_hardBuildTimeout);
            var fragment = await CreateCharacterDataInternal(obj, cts.Token).ConfigureAwait(false);

        fragment.FileReplacements =
                new HashSet<FileReplacement>(resolvedPaths.Select(c => new FileReplacement([.. c.Value], c.Key)), FileReplacementComparer.Instance)
                .Where(p => p.HasFileReplacement).ToHashSet();
        var allowedExtensions = CacheMonitor.AllowedFileExtensions;
        fragment.FileReplacements.RemoveWhere(c => c.GamePaths.Any(g => !allowedExtensions.Any(e => g.EndsWith(e, StringComparison.OrdinalIgnoreCase))));
            _characterBuildCache[key] = new CacheEntry(fragment, DateTime.UtcNow);
            PruneCharacterCacheIfNeeded();

            return fragment;
        }
        finally
        {
            _characterBuildInflight.TryRemove(key, out _);
        }
    }

    private void PruneCharacterCacheIfNeeded()
    {
        if (_characterBuildCache.Count < 2048) return;

        var cutoff = DateTime.UtcNow - TimeSpan.FromSeconds(10);
        foreach (var kv in _characterBuildCache)
        {
            if (kv.Value.CreatedUtc < cutoff)
                _characterBuildCache.TryRemove(kv.Key, out _);
        }
    }

    private static async Task<T> WithCancellation<T>(Task<T> task, CancellationToken ct)
        => await task.WaitAsync(ct).ConfigureAwait(false);

    private async Task<CharacterDataFragment> CreateCharacterDataInternal(GameObjectHandler playerRelatedObject, CancellationToken ct)
    {
        var objectKind = playerRelatedObject.ObjectKind;
        CharacterDataFragment fragment = objectKind == ObjectKind.Player ? new CharacterDataFragmentPlayer() : new();

        var logDebug = _logger.IsEnabled(LogLevel.Debug);
        var sw = Stopwatch.StartNew();

        _logger.LogDebug("Building character data for {obj}", playerRelatedObject);

        await EnsureObjectPresentAsync(playerRelatedObject, ct).ConfigureAwait(false);
        ct.ThrowIfCancellationRequested();

        var waitRecordingTask = _transientResourceManager.WaitForRecording(ct);

        await _dalamudUtil.WaitWhileCharacterIsDrawing(_logger, playerRelatedObject, Guid.NewGuid(), 30000, ct: ct)
            .ConfigureAwait(false);

        // get all remaining paths and resolve them
        var transientPaths = ManageSemiTransientData(objectKind);
        var resolvedTransientPaths = transientPaths.Count == 0
            ? new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase).AsReadOnly()
            : await GetFileReplacementsFromPaths(playerRelatedObject, transientPaths, new HashSet<string>(StringComparer.Ordinal)).ConfigureAwait(false);
        ct.ThrowIfCancellationRequested();

        if (await CheckForNullDrawObject(playerRelatedObject.Address).ConfigureAwait(false))
            throw new InvalidOperationException("DrawObject became null during build (actor despawned)");

        Task<string> getGlamourerData = _ipcManager.Glamourer.GetCharacterCustomizationAsync(playerRelatedObject.Address);
        Task<string?> getCustomizeData = _ipcManager.CustomizePlus.GetScaleAsync(playerRelatedObject.Address);
        Task<string?>? getMoodlesData = null;
        Task<string>? getHeelsOffset = null;
        Task<string>? getHonorificTitle = null;

        if (objectKind == ObjectKind.Player)
        {
            getHeelsOffset = _ipcManager.Heels.GetOffsetAsync();
            getHonorificTitle = _ipcManager.Honorific.GetTitle();
            getMoodlesData = _ipcManager.Moodles.GetStatusAsync(playerRelatedObject.Address);
        }

        var resolvedPaths = await _ipcManager.Penumbra.GetCharacterData(_logger, playerRelatedObject).ConfigureAwait(false) ?? throw new InvalidOperationException("Penumbra returned null data; couldn't proceed with character");
        ct.ThrowIfCancellationRequested();

        var staticBuildTask = Task.Run(() => BuildStaticReplacements(resolvedPaths), ct);

        fragment.FileReplacements = await staticBuildTask.ConfigureAwait(false);

        if (logDebug)
        {
            _logger.LogDebug("== Static Replacements ==");
            foreach (var replacement in fragment.FileReplacements
                         .Where(i => i.HasFileReplacement)
                         .OrderBy(i => i.GamePaths.First(), StringComparer.OrdinalIgnoreCase))
            {
                _logger.LogDebug("=> {repl}", replacement);
                ct.ThrowIfCancellationRequested();
            }
        }

        var staticReplacements = new HashSet<FileReplacement>(fragment.FileReplacements, FileReplacementComparer.Instance);

        var transientTask = ResolveTransientReplacementsAsync(
            playerRelatedObject,
            objectKind,
            staticReplacements,
            waitRecordingTask,
            ct);

        fragment.GlamourerString = await getGlamourerData.ConfigureAwait(false);
        _logger.LogDebug("Glamourer is now: {data}", fragment.GlamourerString);

        var customizeScale = await getCustomizeData.ConfigureAwait(false);
        fragment.CustomizePlusScale = customizeScale ?? string.Empty;
        _logger.LogDebug("Customize is now: {data}", fragment.CustomizePlusScale);

        if (objectKind == ObjectKind.Player)
        {
            CharacterDataFragmentPlayer? playerFragment = fragment as CharacterDataFragmentPlayer ?? throw new InvalidOperationException("Failed to cast CharacterDataFragment to Player variant");

            playerFragment.ManipulationString = _ipcManager.Penumbra.GetMetaManipulations();
            playerFragment.HonorificData = await getHonorificTitle!.ConfigureAwait(false);
            _logger.LogDebug("Honorific is now: {data}", playerFragment!.HonorificData);

            playerFragment.PetNamesData = _ipcManager.PetNames.GetLocalNames();
            _logger.LogDebug("Pet Nicknames is now: {petnames}", playerFragment!.PetNamesData);

            playerFragment.HeelsData = await getHeelsOffset!.ConfigureAwait(false);
            _logger.LogDebug("Heels is now: {heels}", playerFragment!.HeelsData);

            playerFragment.MoodlesData = (await getMoodlesData!.ConfigureAwait(false)) ?? string.Empty;
            _logger.LogDebug("Moodles is now: {moodles}", playerFragment!.MoodlesData);
        }

        ct.ThrowIfCancellationRequested();

        var (resolvedTransientPaths, clearedForPet) = await transientTask.ConfigureAwait(false);
        if (clearedForPet != null)
            fragment.FileReplacements.Clear();

        if (logDebug)
        {
            _logger.LogDebug("== Transient Replacements ==");
            foreach (var replacement in resolvedTransientPaths
                         .Select(c => new FileReplacement([.. c.Value], c.Key))
                         .OrderBy(f => f.ResolvedPath, StringComparer.Ordinal))
            {
                _logger.LogDebug("=> {repl}", replacement);
                fragment.FileReplacements.Add(replacement);
            }
        }
        else
        {
            foreach (var replacement in resolvedTransientPaths.Select(c => new FileReplacement([.. c.Value], c.Key)))
                fragment.FileReplacements.Add(replacement);
        }

        _transientResourceManager.CleanUpSemiTransientResources(objectKind, [.. fragment.FileReplacements]);

        fragment.FileReplacements = new HashSet<FileReplacement>(
            fragment.FileReplacements
                .Where(v => v.HasFileReplacement)
                .OrderBy(v => v.ResolvedPath, StringComparer.Ordinal),
            FileReplacementComparer.Instance);

        ct.ThrowIfCancellationRequested();

        var toCompute = fragment.FileReplacements.Where(f => !f.IsFileSwap).ToArray();
        _logger.LogDebug("Getting Hashes for {amount} Files", toCompute.Length);

        await Task.Run(() =>
        {
            var computedPaths = _fileCacheManager.GetFileCachesByPaths([.. toCompute.Select(c => c.ResolvedPath)]);
            foreach (var file in toCompute)
            {
                ct.ThrowIfCancellationRequested();
                file.Hash = computedPaths[file.ResolvedPath]?.Hash ?? string.Empty;
            }
        }, ct).ConfigureAwait(false);

        var removed = fragment.FileReplacements.RemoveWhere(f => !f.IsFileSwap && string.IsNullOrEmpty(f.Hash));
        if (removed > 0)
            _logger.LogDebug("Removed {amount} of invalid files", removed);

        ct.ThrowIfCancellationRequested();

        Dictionary<string, List<ushort>>? boneIndices = null;
        var hasPapFiles = false;

        if (objectKind == ObjectKind.Player)
        {
            hasPapFiles = fragment.FileReplacements.Any(f =>
                !f.IsFileSwap && f.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)));

            if (hasPapFiles)
            {
                boneIndices = await _dalamudUtil
                    .RunOnFrameworkThread(() => _modelAnalyzer.GetSkeletonBoneIndices(playerRelatedObject))
                    .ConfigureAwait(false);
            }

            try
            {
#if DEBUG
                if (hasPapFiles && boneIndices != null)
                    _modelAnalyzer.DumpLocalSkeletonIndices(playerRelatedObject);
#endif

                if (hasPapFiles)
                {
                    await VerifyPlayerAnimationBones(boneIndices, (CharacterDataFragmentPlayer)fragment, ct)
                        .ConfigureAwait(false);
                }
            }
            catch (OperationCanceledException e)
            {
                _logger.LogDebug(e, "Cancelled during player animation verification");
                throw;
            }
            catch (Exception e)
            {
                _logger.LogWarning(e, "Failed to verify player animations, continuing without further verification");
            }
        }

        _logger.LogInformation("Building character data for {obj} took {time}ms",
            objectKind, sw.Elapsed.TotalMilliseconds);

        return fragment;
    }

    private async Task EnsureObjectPresentAsync(GameObjectHandler handler, CancellationToken ct)
    {
        var remaining = 10000;
        while (remaining > 0)
        {
            ct.ThrowIfCancellationRequested();

            var obj = await _dalamudUtil.CreateGameObjectAsync(handler.Address).ConfigureAwait(false);
            if (await _dalamudUtil.IsObjectPresentAsync(obj).ConfigureAwait(false))
                return;

            _logger.LogTrace("Character is null but it shouldn't be, waiting");
            await Task.Delay(50, ct).ConfigureAwait(false);
            remaining -= 50;
        }
    }

    private static HashSet<FileReplacement> BuildStaticReplacements(Dictionary<string, HashSet<string>> resolvedPaths)
    {
        var set = new HashSet<FileReplacement>(FileReplacementComparer.Instance);

        foreach (var kvp in resolvedPaths)
        {
            var fr = new FileReplacement([.. kvp.Value], kvp.Key);
            if (!fr.HasFileReplacement) continue;

            var allAllowed = fr.GamePaths.All(g =>
                CacheMonitor.AllowedFileExtensions.Any(e => g.EndsWith(e, StringComparison.OrdinalIgnoreCase)));

            if (!allAllowed) continue;

            set.Add(fr);
        }

        return set;
    }

    private async Task<(IReadOnlyDictionary<string, string[]> ResolvedPaths, HashSet<FileReplacement>? ClearedReplacements)>
        ResolveTransientReplacementsAsync(
            GameObjectHandler obj,
            ObjectKind objectKind,
            HashSet<FileReplacement> staticReplacements,
            Task waitRecordingTask,
            CancellationToken ct)
    {
        await waitRecordingTask.ConfigureAwait(false);

        HashSet<FileReplacement>? clearedReplacements = null;

        if (objectKind == ObjectKind.Pet)
        {
            foreach (var item in staticReplacements.Where(i => i.HasFileReplacement).SelectMany(p => p.GamePaths))
            {
                if (_transientResourceManager.AddTransientResource(objectKind, item))
                    _logger.LogDebug("Marking static {item} for Pet as transient", item);
            }

            _logger.LogTrace("Clearing {count} Static Replacements for Pet", staticReplacements.Count);
            clearedReplacements = staticReplacements;
        }

        ct.ThrowIfCancellationRequested();

        _transientResourceManager.ClearTransientPaths(objectKind, [.. staticReplacements.SelectMany(c => c.GamePaths)]);

        var transientPaths = ManageSemiTransientData(objectKind);
        if (transientPaths.Count == 0)
            return (new Dictionary<string, string[]>(StringComparer.Ordinal), clearedReplacements);

        var resolved = await GetFileReplacementsFromPaths(obj, transientPaths, new HashSet<string>(StringComparer.Ordinal))
            .ConfigureAwait(false);

        if (_maxTransientResolvedEntries > 0 && resolved.Count > _maxTransientResolvedEntries)
        {
            _logger.LogWarning("Transient entries ({resolved}) are above the threshold {max}; Please consider disable some mods (VFX have heavy load) to reduce transient load",
                resolved.Count,
                _maxTransientResolvedEntries);
        }

        return (resolved, clearedReplacements);
    }


    private async Task VerifyPlayerAnimationBones(
        Dictionary<string, List<ushort>>? playerBoneIndices,
        CharacterDataFragmentPlayer fragment,
        CancellationToken ct)
    {
        var mode = _configService.Current.AnimationValidationMode;
        var allowBasedShift = _configService.Current.AnimationAllowOneBasedShift;
        var allownNightIndex = _configService.Current.AnimationAllowNeighborIndexTolerance;

        if (mode == AnimationValidationMode.Unsafe)
            return;

        if (playerBoneIndices == null || playerBoneIndices.Count == 0)
            return;

        var localBoneSets = new Dictionary<string, HashSet<ushort>>(StringComparer.OrdinalIgnoreCase);

        foreach (var (rawLocalKey, indices) in playerBoneIndices)
        {
            if (indices is not { Count: > 0 })
                continue;

            var key = XivDataAnalyzer.CanonicalizeSkeletonKey(rawLocalKey);
            if (string.IsNullOrEmpty(key))
                continue;

            if (!localBoneSets.TryGetValue(key, out var set))
                localBoneSets[key] = set = [];

            foreach (var idx in indices)
                set.Add(idx);
        }

        if (localBoneSets.Count == 0)
            return;

        if (_logger.IsEnabled(LogLevel.Debug))
        {
            _logger.LogDebug("SEND local buckets: {b}",
                string.Join(", ", localBoneSets.Keys.Order(StringComparer.Ordinal)));

            foreach (var kvp in localBoneSets.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase))
            {
                var min = kvp.Value.Count > 0 ? kvp.Value.Min() : 0;
                var max = kvp.Value.Count > 0 ? kvp.Value.Max() : 0;
                _logger.LogDebug("Local bucket {bucket}: count={count} min={min} max={max}",
                    kvp.Key, kvp.Value.Count, min, max);
            }
        }

        var papGroups = fragment.FileReplacements
            .Where(f => !f.IsFileSwap
                        && !string.IsNullOrEmpty(f.Hash)
                        && f.GamePaths is { Count: > 0 }
                        && f.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)))
            .GroupBy(f => f.Hash!, StringComparer.OrdinalIgnoreCase)
            .ToList();

        int noValidationFailed = 0;

        foreach (var g in papGroups)
        {
            ct.ThrowIfCancellationRequested();

            var hash = g.Key;

            Dictionary<string, List<ushort>>? papIndices = null;

            await _papParseLimiter.WaitAsync(ct).ConfigureAwait(false);
            try
            {
                papIndices = await Task.Run(() => _modelAnalyzer.GetBoneIndicesFromPap(hash), ct)
                    .ConfigureAwait(false);
            }
            finally
            {
                _papParseLimiter.Release();
            }

            if (papIndices == null || papIndices.Count == 0)
                continue;

            if (papIndices.All(k => k.Value.DefaultIfEmpty().Max() <= 105))
                continue;

            if (_logger.IsEnabled(LogLevel.Debug))
            {
                var papBuckets = papIndices
                    .Select(kvp => new
                    {
                        Raw = kvp.Key,
                        Key = XivDataAnalyzer.CanonicalizeSkeletonKey(kvp.Key),
                        Indices = kvp.Value
                    })
                    .Where(x => x.Indices is { Count: > 0 })
                    .GroupBy(x => string.IsNullOrEmpty(x.Key) ? x.Raw : x.Key!, StringComparer.OrdinalIgnoreCase)
                    .Select(grp =>
                    {
                        var all = grp.SelectMany(v => v.Indices).ToList();
                        var min = all.Count > 0 ? all.Min() : 0;
                        var max = all.Count > 0 ? all.Max() : 0;
                        var raws = string.Join(',', grp.Select(v => v.Raw).Distinct(StringComparer.OrdinalIgnoreCase));
                        return $"{grp.Key}(min={min},max={max},raw=[{raws}])";
                    })
                    .ToList();

                _logger.LogDebug("SEND pap buckets for hash={hash}: {b}",
                    hash,
                    string.Join(" | ", papBuckets));
            }

            if (XivDataAnalyzer.IsPapCompatible(localBoneSets, papIndices, mode, allowBasedShift, allownNightIndex, out var reason))
                continue;

            noValidationFailed++;

            _logger.LogWarning(
                "Animation PAP hash {hash} is not compatible with local skeletons; dropping all mappings for this hash. Reason: {reason}",
                hash,
                reason);

            var removedGamePaths = fragment.FileReplacements
                .Where(fr => !fr.IsFileSwap
                             && string.Equals(fr.Hash, hash, StringComparison.OrdinalIgnoreCase)
                             && fr.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)))
                .SelectMany(fr => fr.GamePaths.Where(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)))
                .Distinct(StringComparer.OrdinalIgnoreCase)
                .ToList();

            fragment.FileReplacements.RemoveWhere(fr =>
                !fr.IsFileSwap
                && string.Equals(fr.Hash, hash, StringComparison.OrdinalIgnoreCase)
                && fr.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)));

            foreach (var gp in removedGamePaths)
                _transientResourceManager.RemoveTransientResource(ObjectKind.Player, gp);
        }

        if (noValidationFailed > 0)
        {
            _lightlessMediator.Publish(new NotificationMessage(
                "Invalid Skeleton Setup",
                $"Your client is attempting to send {noValidationFailed} animation files that don't match your current skeleton validation mode ({mode}). " +
                "Please adjust your skeleton/mods or change the validation mode if this is unexpected. " +
                "Those animation files have been removed from your sent (player) data. (Check /xllog for details).",
                NotificationType.Warning,
                TimeSpan.FromSeconds(10)));
        }
    }


    private async Task<IReadOnlyDictionary<string, string[]>> GetFileReplacementsFromPaths(
        GameObjectHandler handler,
        HashSet<string> forwardResolve,
        HashSet<string> reverseResolve)
    {
        var forwardPaths = forwardResolve.ToArray();
        var reversePaths = reverseResolve.ToArray();
        if (forwardPaths.Length == 0 && reversePaths.Length == 0)
        {
            return new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase).AsReadOnly();
        }

        var forwardPathsLower = forwardPaths.Length == 0 ? Array.Empty<string>() : forwardPaths.Select(p => p.ToLowerInvariant()).ToArray();
        var reversePathsLower = reversePaths.Length == 0 ? Array.Empty<string>() : reversePaths.Select(p => p.ToLowerInvariant()).ToArray();

        Dictionary<string, List<string>> resolvedPaths = new(forwardPaths.Length + reversePaths.Length, StringComparer.Ordinal);
        if (handler.ObjectKind != ObjectKind.Player)
        {
            var (objectIndex, forwardResolved, reverseResolved) = await _dalamudUtil.RunOnFrameworkThread(() =>
            {
                var idx = handler.GetGameObject()?.ObjectIndex;
                if (!idx.HasValue)
                    return ((int?)null, Array.Empty<string>(), Array.Empty<string[]>());

                var resolvedForward = new string[forwardPaths.Length];
                for (int i = 0; i < forwardPaths.Length; i++)
                    resolvedForward[i] = _ipcManager.Penumbra.ResolveGameObjectPath(forwardPaths[i], idx.Value);

                var resolvedReverse = new string[reversePaths.Length][];
                for (int i = 0; i < reversePaths.Length; i++)
                    resolvedReverse[i] = _ipcManager.Penumbra.ReverseResolveGameObjectPath(reversePaths[i], idx.Value);

                return (idx, resolvedForward, resolvedReverse);
            }).ConfigureAwait(false);

            if (objectIndex.HasValue)
            {
                for (int i = 0; i < forwardPaths.Length; i++)
                {
                    var filePath = forwardResolved[i]?.ToLowerInvariant();
                    if (string.IsNullOrEmpty(filePath))
                        continue;

                    if (resolvedPaths.TryGetValue(filePath, out var list))
                        list.Add(forwardPaths[i].ToLowerInvariant());
                    else
                    {
                        resolvedPaths[filePath] = [forwardPaths[i].ToLowerInvariant()];
                    }
                }

                for (int i = 0; i < reversePaths.Length; i++)
                {
                    var filePath = reversePathsLower[i];
                    var reverseResolvedLower = new string[reverseResolved[i].Length];
                    for (var j = 0; j < reverseResolvedLower.Length; j++)
                    {
                        reverseResolvedLower[j] = reverseResolved[i][j].ToLowerInvariant();
                    }
                    if (resolvedPaths.TryGetValue(filePath, out var list))
                        list.AddRange(reverseResolved[i].Select(c => c.ToLowerInvariant()));
                    else
                        resolvedPaths[filePath] = [.. reverseResolved[i].Select(c => c.ToLowerInvariant()).ToList()];
                }

                return resolvedPaths.ToDictionary(k => k.Key, k => k.Value.ToArray(), StringComparer.OrdinalIgnoreCase).AsReadOnly();
            }
        }

        var (forward, reverse) = await _ipcManager.Penumbra.ResolvePathsAsync(forwardPaths, reversePaths).ConfigureAwait(false);

        for (int i = 0; i < forwardPaths.Length; i++)
        {
            var filePath = forward[i].ToLowerInvariant();
            if (resolvedPaths.TryGetValue(filePath, out var list))
                list.Add(forwardPaths[i].ToLowerInvariant());
            else
                resolvedPaths[filePath] = [forwardPaths[i].ToLowerInvariant()];
        }

        for (int i = 0; i < reversePaths.Length; i++)
        {
            var filePath = reversePathsLower[i];
            var reverseResolvedLower = new string[reverse[i].Length];
            for (var j = 0; j < reverseResolvedLower.Length; j++)
            {
                reverseResolvedLower[j] = reverse[i][j].ToLowerInvariant();
            }
            if (resolvedPaths.TryGetValue(filePath, out var list))
                list.AddRange(reverse[i].Select(c => c.ToLowerInvariant()));
            else
                resolvedPaths[filePath] = [.. reverse[i].Select(c => c.ToLowerInvariant()).ToList()];
        }

        return resolvedPaths.ToDictionary(k => k.Key, k => k.Value.ToArray(), StringComparer.OrdinalIgnoreCase).AsReadOnly();
    }

    private HashSet<string> ManageSemiTransientData(ObjectKind objectKind)
    {
        _transientResourceManager.PersistTransientResources(objectKind);

        HashSet<string> pathsToResolve = new(StringComparer.Ordinal);

        int scanned = 0, skippedEmpty = 0, skippedVfx = 0;

        foreach (var path in _transientResourceManager.GetSemiTransientResources(objectKind))
        {
            scanned++;

            if (string.IsNullOrEmpty(path))
            {
                skippedEmpty++;
                continue;
            }

            pathsToResolve.Add(path);
        }

        if (_logger.IsEnabled(LogLevel.Debug))
        {
            _logger.LogDebug(
                "ManageSemiTransientData({kind}): scanned={scanned}, added={added}, skippedEmpty={skippedEmpty}, skippedVfx={skippedVfx}",
                objectKind, scanned, pathsToResolve.Count, skippedEmpty, skippedVfx);
        }

        return pathsToResolve;
    }
}
