using System.Collections.Concurrent;
using PlotLine.Data;
using PlotLine.Models;
using SkiaSharp;

namespace PlotLine.Services;

public interface IVisualIdentityImageService
{
    Task<VisualIdentityImageResult> UploadCharacterImageAsync(Character character, IFormFile upload);
    Task<VisualIdentityImageResult> UploadCharacterGalleryImageAsync(Character character, IFormFile upload, string? caption);
    Task<VisualIdentityImageResult> CreateCharacterAvatarAsync(Character character, CharacterImage sourceImage, int cropX, int cropY, int cropSize);
    Task RemoveCharacterImageAsync(Character character);
    Task RemoveCharacterAvatarAsync(Character character);
    Task DeleteCharacterGalleryImageAsync(CharacterImage image);
    Task<VisualIdentityImageResult> UploadAssetImageAsync(StoryAsset asset, IFormFile upload);
    Task RemoveAssetImageAsync(StoryAsset asset);
}

public sealed record VisualIdentityImageResult(bool Succeeded, string? ErrorMessage = null, int? CharacterImageID = null);

public sealed class VisualIdentityImageService(
    IUploadStorageService uploadStorage,
    IProjectCollaborationRepository collaboration,
    ISubscriptionService subscriptions,
    IUserFileRepository userFiles,
    ICharacterRepository characters,
    IAssetRepository assets,
    ILogger<VisualIdentityImageService> logger) : IVisualIdentityImageService
{
    private const long MaxUploadBytes = 10L * 1024L * 1024L;
    private const int ThumbnailSize = 128;
    private const int StandardMaxSize = 1200;
    private static readonly HashSet<string> SupportedExtensions = new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp" };
    private static readonly ConcurrentDictionary<string, SemaphoreSlim> EntityLocks = new();

    public async Task<VisualIdentityImageResult> UploadCharacterImageAsync(Character character, IFormFile upload)
        => await UploadCharacterGalleryImageCoreAsync(character, upload, null, updateLegacyImage: true);

    public async Task<VisualIdentityImageResult> UploadCharacterGalleryImageAsync(Character character, IFormFile upload, string? caption)
        => await UploadCharacterGalleryImageCoreAsync(character, upload, caption, updateLegacyImage: false);

    public Task RemoveCharacterImageAsync(Character character)
        => RemoveAsync(
            character.ImagePath,
            character.ThumbnailPath,
            "uploads/characters",
            (imagePath, thumbnailPath) => characters.UpdateCharacterImageAsync(character.CharacterID, imagePath, thumbnailPath));

    public async Task RemoveCharacterAvatarAsync(Character character)
    {
        await characters.UpdateCharacterAvatarAsync(character.CharacterID, null, null, null);
        await MarkPreviousLedgerRowsAsync(character.AvatarImagePath);
        await MarkPreviousLedgerRowsAsync(character.AvatarThumbnailPath);
        DeletePreviousPhysicalFile(character.AvatarImagePath, null, "uploads/characters");
        DeletePreviousPhysicalFile(character.AvatarThumbnailPath, null, "uploads/characters");
    }

    public async Task DeleteCharacterGalleryImageAsync(CharacterImage image)
    {
        await characters.DeleteCharacterImageAsync(image.CharacterImageID);
        await MarkPreviousLedgerRowsAsync(image.ImagePath);
        await MarkPreviousLedgerRowsAsync(image.ThumbnailPath);
        DeletePreviousPhysicalFile(image.ImagePath, null, "uploads/characters");
        DeletePreviousPhysicalFile(image.ThumbnailPath, null, "uploads/characters");
    }

    public async Task<VisualIdentityImageResult> CreateCharacterAvatarAsync(Character character, CharacterImage sourceImage, int cropX, int cropY, int cropSize)
    {
        if (sourceImage.CharacterID != character.CharacterID)
        {
            return new(false, "Choose an image from this character's gallery.");
        }

        var sourcePath = uploadStorage.TryResolvePublicPath(sourceImage.ImagePath, "uploads/characters");
        if (sourcePath is null || !File.Exists(sourcePath))
        {
            return new(false, "The source image could not be found.");
        }

        var ownerUserId = await collaboration.GetOwnerUserIdAsync(character.ProjectID);
        if (!ownerUserId.HasValue)
        {
            return new(false, "Project owner could not be found.");
        }

        var storageLimit = await subscriptions.CanUploadFileAsync(ownerUserId.Value, 256 * 256);
        if (!storageLimit.Allowed)
        {
            return new(false, storageLimit.Message);
        }

        try
        {
            using var bitmap = SKBitmap.Decode(sourcePath) ?? throw new InvalidDataException();
            var size = cropSize <= 0 ? Math.Min(bitmap.Width, bitmap.Height) : cropSize;
            size = Math.Min(size, Math.Min(bitmap.Width, bitmap.Height));
            cropX = Math.Clamp(cropX, 0, Math.Max(0, bitmap.Width - size));
            cropY = Math.Clamp(cropY, 0, Math.Max(0, bitmap.Height - size));

            var uploadRoot = uploadStorage.GetDirectory("characters", character.CharacterID.ToString());
            var version = Guid.NewGuid().ToString("N");
            var avatarTemp = Path.Combine(uploadRoot, $"avatar-{version}.tmp");
            var avatarPath = Path.Combine(uploadRoot, "avatar.webp");
            var avatarStoragePath = uploadStorage.GetPublicPath("characters", character.CharacterID.ToString(), "avatar.webp");

            SaveWebp(bitmap, avatarTemp, new SKRect(cropX, cropY, cropX + size, cropY + size), 256, 256, 82);
            ReplaceFile(avatarTemp, avatarPath);

            await characters.UpdateCharacterAvatarAsync(character.CharacterID, sourceImage.CharacterImageID, avatarStoragePath, avatarStoragePath);
            await MarkPreviousLedgerRowsAsync(character.AvatarImagePath);
            await MarkPreviousLedgerRowsAsync(character.AvatarThumbnailPath);
            await TrackImageAsync(ownerUserId.Value, character.ProjectID, character.CharacterID, "CharacterAvatar", avatarStoragePath, "Character avatar", new FileInfo(avatarPath).Length);
            DeletePreviousPhysicalFile(character.AvatarImagePath, avatarStoragePath, "uploads/characters");
            DeletePreviousPhysicalFile(character.AvatarThumbnailPath, avatarStoragePath, "uploads/characters");
            return new(true);
        }
        catch (Exception ex) when (ex is InvalidDataException or IOException or ArgumentException)
        {
            logger.LogWarning(ex, "Character avatar crop failed for Character {CharacterID}.", character.CharacterID);
            return new(false, "Avatar could not be generated. Try another crop or source image.");
        }
    }

    public Task<VisualIdentityImageResult> UploadAssetImageAsync(StoryAsset asset, IFormFile upload)
        => UploadAsync(
            $"asset:{asset.StoryAssetID}",
            asset.ProjectID,
            asset.StoryAssetID,
            asset.ImagePath,
            asset.ThumbnailPath,
            "story-assets",
            "StoryAssetImage",
            (imagePath, thumbnailPath) => assets.UpdateAssetImageAsync(asset.StoryAssetID, imagePath, thumbnailPath),
            upload);

    public Task RemoveAssetImageAsync(StoryAsset asset)
        => RemoveAsync(
            asset.ImagePath,
            asset.ThumbnailPath,
            "uploads/story-assets",
            (imagePath, thumbnailPath) => assets.UpdateAssetImageAsync(asset.StoryAssetID, imagePath, thumbnailPath));

    private async Task<VisualIdentityImageResult> UploadAsync(
        string lockKey,
        int projectId,
        int entityId,
        string? oldImagePath,
        string? oldThumbnailPath,
        string folderName,
        string entityType,
        Func<string?, string?, Task> updateImage,
        IFormFile upload)
    {
        var uploadLock = EntityLocks.GetOrAdd(lockKey, _ => new SemaphoreSlim(1, 1));
        await uploadLock.WaitAsync();
        try
        {
            return await UploadCoreAsync(projectId, entityId, oldImagePath, oldThumbnailPath, folderName, entityType, updateImage, upload);
        }
        finally
        {
            uploadLock.Release();
        }
    }

    private async Task<VisualIdentityImageResult> UploadCoreAsync(
        int projectId,
        int entityId,
        string? oldImagePath,
        string? oldThumbnailPath,
        string folderName,
        string entityType,
        Func<string?, string?, Task> updateImage,
        IFormFile upload)
    {
        if (upload.Length <= 0)
        {
            return new(false, "Choose an image to upload.");
        }

        if (upload.Length > MaxUploadBytes)
        {
            return new(false, "Image must be 10 MB or smaller.");
        }

        if (!SupportedExtensions.Contains(Path.GetExtension(upload.FileName)))
        {
            return new(false, "Image must be a JPG, PNG or WebP file.");
        }

        var ownerUserId = await collaboration.GetOwnerUserIdAsync(projectId);
        if (!ownerUserId.HasValue)
        {
            return new(false, "Project owner could not be found.");
        }

        var storageLimit = await subscriptions.CanUploadFileAsync(ownerUserId.Value, EstimatedGeneratedSize(upload.Length));
        if (!storageLimit.Allowed)
        {
            return new(false, storageLimit.Message);
        }

        SKBitmap bitmap;
        try
        {
            await using var uploadStream = upload.OpenReadStream();
            using var memory = new MemoryStream();
            await uploadStream.CopyToAsync(memory);
            memory.Position = 0;

            using var codec = SKCodec.Create(memory) ?? throw new InvalidDataException();
            if (codec.EncodedFormat is not (SKEncodedImageFormat.Jpeg or SKEncodedImageFormat.Png or SKEncodedImageFormat.Webp))
            {
                return new(false, "Image must be a valid JPG, PNG or WebP file.");
            }

            memory.Position = 0;
            bitmap = SKBitmap.Decode(memory) ?? throw new InvalidDataException();
        }
        catch (Exception ex) when (ex is InvalidDataException or ArgumentException)
        {
            return new(false, "Image must be a valid JPG, PNG or WebP file.");
        }

        using (bitmap)
        {
            if (bitmap.Width <= 0 || bitmap.Height <= 0)
            {
                return new(false, "Image could not be read as a valid image.");
            }

            var uploadRoot = uploadStorage.GetDirectory(folderName, entityId.ToString());
            var version = Guid.NewGuid().ToString("N");
            var imageTemp = Path.Combine(uploadRoot, $"image-{version}.tmp");
            var thumbnailTemp = Path.Combine(uploadRoot, $"thumb-{version}.tmp");
            var imagePath = Path.Combine(uploadRoot, "image.webp");
            var thumbnailPath = Path.Combine(uploadRoot, "thumb.webp");
            var imageStoragePath = uploadStorage.GetPublicPath(folderName, entityId.ToString(), "image.webp");
            var thumbnailStoragePath = uploadStorage.GetPublicPath(folderName, entityId.ToString(), "thumb.webp");

            try
            {
                SaveContainedWebp(bitmap, imageTemp, StandardMaxSize, 82);
                SaveCroppedWebp(bitmap, thumbnailTemp, ThumbnailSize, ThumbnailSize, 76);

                ReplaceFile(imageTemp, imagePath);
                ReplaceFile(thumbnailTemp, thumbnailPath);

                await updateImage(imageStoragePath, thumbnailStoragePath);
                await MarkPreviousLedgerRowsAsync(oldImagePath);
                await MarkPreviousLedgerRowsAsync(oldThumbnailPath);
                await TrackImageAsync(ownerUserId.Value, projectId, entityId, entityType, imageStoragePath, upload.FileName, new FileInfo(imagePath).Length);
                await TrackImageAsync(ownerUserId.Value, projectId, entityId, entityType, thumbnailStoragePath, "Thumbnail: " + upload.FileName, new FileInfo(thumbnailPath).Length);
                DeletePreviousPhysicalFile(oldImagePath, imageStoragePath, $"uploads/{folderName}");
                DeletePreviousPhysicalFile(oldThumbnailPath, thumbnailStoragePath, $"uploads/{folderName}");
                return new(true);
            }
            catch (Exception ex)
            {
                TryDeleteFile(imageTemp);
                TryDeleteFile(thumbnailTemp);
                logger.LogWarning(ex, "Visual identity image upload failed for {EntityType} {EntityID}.", entityType, entityId);
                return new(false, "Image could not be processed. Try another image.");
            }
        }
    }

    private async Task<VisualIdentityImageResult> UploadCharacterGalleryImageCoreAsync(Character character, IFormFile upload, string? caption, bool updateLegacyImage)
    {
        var uploadLock = EntityLocks.GetOrAdd($"character-gallery:{character.CharacterID}", _ => new SemaphoreSlim(1, 1));
        await uploadLock.WaitAsync();
        try
        {
            if (upload.Length <= 0)
            {
                return new(false, "Choose an image to upload.");
            }

            if (upload.Length > MaxUploadBytes)
            {
                return new(false, "Image must be 10 MB or smaller.");
            }

            if (!SupportedExtensions.Contains(Path.GetExtension(upload.FileName)))
            {
                return new(false, "Image must be a JPG, PNG or WebP file.");
            }

            var ownerUserId = await collaboration.GetOwnerUserIdAsync(character.ProjectID);
            if (!ownerUserId.HasValue)
            {
                return new(false, "Project owner could not be found.");
            }

            var storageLimit = await subscriptions.CanUploadFileAsync(ownerUserId.Value, EstimatedGeneratedSize(upload.Length));
            if (!storageLimit.Allowed)
            {
                return new(false, storageLimit.Message);
            }

            SKBitmap bitmap;
            try
            {
                await using var uploadStream = upload.OpenReadStream();
                using var memory = new MemoryStream();
                await uploadStream.CopyToAsync(memory);
                memory.Position = 0;

                using var codec = SKCodec.Create(memory) ?? throw new InvalidDataException();
                if (codec.EncodedFormat is not (SKEncodedImageFormat.Jpeg or SKEncodedImageFormat.Png or SKEncodedImageFormat.Webp))
                {
                    return new(false, "Image must be a valid JPG, PNG or WebP file.");
                }

                memory.Position = 0;
                bitmap = SKBitmap.Decode(memory) ?? throw new InvalidDataException();
            }
            catch (Exception ex) when (ex is InvalidDataException or ArgumentException)
            {
                return new(false, "Image must be a valid JPG, PNG or WebP file.");
            }

            using (bitmap)
            {
                if (bitmap.Width <= 0 || bitmap.Height <= 0)
                {
                    return new(false, "Image could not be read as a valid image.");
                }

                var version = Guid.NewGuid().ToString("N");
                var uploadRoot = uploadStorage.GetDirectory("characters", character.CharacterID.ToString(), "gallery", version);
                var imageTemp = Path.Combine(uploadRoot, "image.tmp");
                var thumbnailTemp = Path.Combine(uploadRoot, "thumb.tmp");
                var imagePath = Path.Combine(uploadRoot, "image.webp");
                var thumbnailPath = Path.Combine(uploadRoot, "thumb.webp");
                var imageStoragePath = uploadStorage.GetPublicPath("characters", character.CharacterID.ToString(), "gallery", version, "image.webp");
                var thumbnailStoragePath = uploadStorage.GetPublicPath("characters", character.CharacterID.ToString(), "gallery", version, "thumb.webp");

                try
                {
                    SaveContainedWebp(bitmap, imageTemp, StandardMaxSize, 82);
                    SaveCroppedWebp(bitmap, thumbnailTemp, ThumbnailSize, ThumbnailSize, 76);

                    ReplaceFile(imageTemp, imagePath);
                    ReplaceFile(thumbnailTemp, thumbnailPath);

                    var characterImageId = await characters.CreateCharacterImageAsync(new CharacterImage
                    {
                        CharacterID = character.CharacterID,
                        ProjectID = character.ProjectID,
                        ImagePath = imageStoragePath,
                        ThumbnailPath = thumbnailStoragePath,
                        Caption = caption
                    });

                    if (updateLegacyImage || string.IsNullOrWhiteSpace(character.ImagePath))
                    {
                        await characters.UpdateCharacterImageAsync(character.CharacterID, imageStoragePath, thumbnailStoragePath);
                    }

                    await TrackImageAsync(ownerUserId.Value, character.ProjectID, characterImageId, "CharacterImage", imageStoragePath, upload.FileName, new FileInfo(imagePath).Length);
                    await TrackImageAsync(ownerUserId.Value, character.ProjectID, characterImageId, "CharacterImage", thumbnailStoragePath, "Thumbnail: " + upload.FileName, new FileInfo(thumbnailPath).Length);
                    return new(true, CharacterImageID: characterImageId);
                }
                catch (Exception ex)
                {
                    TryDeleteFile(imageTemp);
                    TryDeleteFile(thumbnailTemp);
                    logger.LogWarning(ex, "Character gallery image upload failed for Character {CharacterID}.", character.CharacterID);
                    return new(false, "Image could not be processed. Try another image.");
                }
            }
        }
        finally
        {
            uploadLock.Release();
        }
    }

    private async Task RemoveAsync(string? imagePath, string? thumbnailPath, string requiredPrefix, Func<string?, string?, Task> updateImage)
    {
        await updateImage(null, null);
        await MarkPreviousLedgerRowsAsync(imagePath);
        await MarkPreviousLedgerRowsAsync(thumbnailPath);
        DeletePreviousPhysicalFile(imagePath, null, requiredPrefix);
        DeletePreviousPhysicalFile(thumbnailPath, null, requiredPrefix);
    }

    private static void SaveContainedWebp(SKBitmap source, string path, int maxSize, int quality)
    {
        var scale = Math.Min(maxSize / (float)source.Width, maxSize / (float)source.Height);
        scale = Math.Min(scale, 1f);
        var width = Math.Max(1, (int)Math.Round(source.Width * scale));
        var height = Math.Max(1, (int)Math.Round(source.Height * scale));
        SaveWebp(source, path, new SKRect(0, 0, source.Width, source.Height), width, height, quality);
    }

    private static void SaveCroppedWebp(SKBitmap source, string path, int width, int height, int quality)
    {
        var sourceRatio = source.Width / (float)source.Height;
        var targetRatio = width / (float)height;
        SKRect sourceRect;

        if (sourceRatio > targetRatio)
        {
            var cropWidth = source.Height * targetRatio;
            var left = (source.Width - cropWidth) / 2f;
            sourceRect = new SKRect(left, 0, left + cropWidth, source.Height);
        }
        else
        {
            var cropHeight = source.Width / targetRatio;
            var top = (source.Height - cropHeight) / 2f;
            sourceRect = new SKRect(0, top, source.Width, top + cropHeight);
        }

        SaveWebp(source, path, sourceRect, width, height, quality);
    }

    private static void SaveWebp(SKBitmap source, string path, SKRect sourceRect, int width, int height, int quality)
    {
        using var surface = SKSurface.Create(new SKImageInfo(width, height));
        var canvas = surface.Canvas;
        canvas.Clear(SKColors.Transparent);
        using var paint = new SKPaint { IsAntialias = true };
        using var sourceImage = SKImage.FromBitmap(source);
        canvas.DrawImage(sourceImage, sourceRect, new SKRect(0, 0, width, height), new SKSamplingOptions(SKCubicResampler.Mitchell), paint);
        canvas.Flush();

        using var image = surface.Snapshot();
        using var data = image.Encode(SKEncodedImageFormat.Webp, quality);
        using var output = File.Create(path);
        data.SaveTo(output);
    }

    private async Task TrackImageAsync(int ownerUserId, int projectId, int entityId, string entityType, string storagePath, string originalFileName, long fileSizeBytes)
    {
        await userFiles.CreateAsync(new UserFile
        {
            UserID = ownerUserId,
            ProjectID = projectId,
            EntityType = entityType,
            EntityID = entityId,
            FileName = Path.GetFileName(storagePath),
            OriginalFileName = originalFileName,
            ContentType = "image/webp",
            FileSizeBytes = fileSizeBytes,
            StoragePath = storagePath
        });
    }

    private async Task MarkPreviousLedgerRowsAsync(string? oldStoragePath)
    {
        if (!string.IsNullOrWhiteSpace(oldStoragePath))
        {
            await userFiles.MarkDeletedByStoragePathAsync(oldStoragePath);
        }
    }

    private void DeletePreviousPhysicalFile(string? oldStoragePath, string? currentStoragePath, string requiredPrefix)
    {
        if (string.IsNullOrWhiteSpace(oldStoragePath)
            || string.Equals(oldStoragePath, currentStoragePath, StringComparison.OrdinalIgnoreCase))
        {
            return;
        }

        uploadStorage.TryDeleteFile(oldStoragePath, requiredPrefix);
    }

    private static void ReplaceFile(string tempPath, string finalPath)
    {
        if (File.Exists(finalPath))
        {
            File.Delete(finalPath);
        }

        File.Move(tempPath, finalPath);
    }

    private static void TryDeleteFile(string path)
    {
        if (File.Exists(path))
        {
            File.Delete(path);
        }
    }

    private static long EstimatedGeneratedSize(long uploadBytes)
        => Math.Max(1, Math.Min(uploadBytes, MaxUploadBytes));
}
