using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Options;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
using SkiaSharp;

namespace PlotLine.Services;

public interface IIllustrationPromptBuilder
{
    IllustrationPromptBuildResult Build(IllustrationGenerationSpecification specification);
}

public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder
{
    public const string CurrentTemplateVersion = "21J.3";

    private static readonly JsonSerializerOptions JsonOptions = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        WriteIndented = true
    };

    public IllustrationPromptBuildResult Build(IllustrationGenerationSpecification specification)
    {
        var errors = specification.Validate();
        if (errors.Count > 0)
        {
            throw new ArgumentException(string.Join(" ", errors), nameof(specification));
        }

        var metadata = new Dictionary<string, string>(specification.Metadata, StringComparer.OrdinalIgnoreCase);
        AddMetadata("category", specification.Category);
        AddMetadata("metadataConfidence", "High");
        if (string.Equals(specification.Category, IllustrationLibraryCategories.Character, StringComparison.OrdinalIgnoreCase))
        {
            AddMetadata("ageBand", specification.ApparentAgeBand);
            AddMetadata("presentation", specification.Presentation);
            AddMetadata("hairColour", specification.HairColour);
            AddMetadata("skinTone", specification.SkinTone);
            AddMetadata("hairLength", specification.HairLength);
            AddMetadata("glasses", specification.Features.Any(feature => feature.Contains("glasses", StringComparison.OrdinalIgnoreCase)) ? "Yes" : "Unknown");
            AddMetadata("facialHair", specification.Features.FirstOrDefault(feature => feature.Contains("beard", StringComparison.OrdinalIgnoreCase) || feature.Contains("moustache", StringComparison.OrdinalIgnoreCase)));
            AddMetadata("clothingEra", specification.ClothingEra);
            AddMetadata("clothingStyle", specification.ClothingStyle);
        }

        var metadataJson = JsonSerializer.Serialize(metadata, JsonOptions);
        var specificationJson = JsonSerializer.Serialize(specification, JsonOptions);
        var palette = specification.Palette.Count == 0 ? "balanced editorial colour, not monochrome" : string.Join(", ", specification.Palette);
        var tags = specification.MetadataTags.Count == 0 ? "generic reusable story identifier" : string.Join(", ", specification.MetadataTags);

        var typedDetails = TypedDetails(specification);
        var categoryRules = CategoryRules(specification.Category);

        var prompt = $"""
            Create a high-quality editorial illustration for PlotDirector's reusable Story Intelligence illustration library.
            Category: {specification.Category}
            Library code: {specification.Code}
            Title: {specification.Title}
            Subject: {specification.VisualSubject}
            Structured details: {typedDetails}
            Description: {specification.Description}
            Composition: {specification.Composition}
            Mood: {specification.Mood}
            Palette guidance: {palette}
            Metadata tags: {tags}
            Category composition rules: {categoryRules}

            Requirements:
            - representative visual identifier only, not a canonical depiction of any manuscript
            - modern cinematic digital illustration with premium narrative game concept art quality
            - subdued atmospheric background treatment with contemporary polished rendering, realistic anatomy and believable proportions
            - balanced cinematic lighting, clear facial illumination where faces are present, and strong local contrast
            - crisp subject definition with strong foreground-background separation and clean readable silhouettes
            - natural controlled colour with rich but restrained accents and warm skin tones where appropriate
            - polished promotional artwork for a premium story-driven adventure game, not a literal photograph
            - subtle depth of field, clean modern rendering, and enough exposure to remain clear inside PlotDirector's dark navy interface
            - no visible text, captions, logos, watermarks, UI, signatures, or typography
            - no anime, cartoon, Pixar-like, caricature, celebrity likeness, or photoreal identity claim
            - for character images, the specified hair colour, hair length, apparent age, era, location context and skin tone are hard requirements; do not reinterpret or diversify away from them
            - no bright flat studio backdrop, no plain colour-block background, no stock-photo lighting, and no cartoon simplicity
            - avoid private manuscript details, real names, excerpts, or recognisable copyrighted characters
            - avoid muddy olive and brown grading, heavy sepia tones, underexposure, excessive darkness, low-contrast lighting, flat colour, antique oil-painting texture, distressed canvas texture, rough brushwork, overly sombre default expressions, backgrounds that merge into the subject, and monochrome or near-monochrome results
            - square composition that still reads clearly as a small thumbnail
            """;

        return new(prompt, CurrentTemplateVersion, specificationJson, metadataJson);

        void AddMetadata(string key, string? value)
        {
            if (!string.IsNullOrWhiteSpace(value))
            {
                metadata[key] = value;
            }
        }
    }

    private static string CategoryRules(string category)
        => category switch
        {
            IllustrationLibraryCategories.Character => "Square image; head-and-shoulders or upper body; one person only; face-forward enough to read at small size; naturally expressive; cleanly lit face; distinct from a subtle atmospheric story background; suitable for circular cropping; avoid bright flat studio backgrounds, plain colour blocks, dark collars vanishing into black backgrounds, and photographic headshot styling.",
            IllustrationLibraryCategories.Location => "One clear focal environment; vivid and readable; clearly lit and visually inviting even when dramatic; rich colour and depth; strong composition for circular and landscape crops; no prominent people; no text or readable signage; do not default every location to nighttime, rain-soaked, blue-black, ominous, or haunted-looking.",
            IllustrationLibraryCategories.Asset => "Square image; one dominant centred object; bright clean high-contrast presentation; crisp silhouette; clear separation from background; immediately recognisable at small size; entire object visible where practical.",
            _ => string.Empty
        };

    private static string TypedDetails(IllustrationGenerationSpecification specification)
        => specification.Category switch
        {
            IllustrationLibraryCategories.Character => string.Join("; ", new[]
            {
                $"apparent age band {specification.ApparentAgeBand}",
                $"presentation {specification.Presentation}",
                $"hair {specification.HairLength} {specification.HairColour}",
                $"required skin tone {specification.SkinTone}",
                $"features {string.Join(", ", specification.Features)}",
                $"clothing {specification.ClothingEra} {specification.ClothingStyle}"
            }.Where(item => !item.EndsWith(" ", StringComparison.Ordinal))),
            IllustrationLibraryCategories.Location => $"location type {specification.LocationType}; era {specification.Era}; setting {specification.Setting}",
            IllustrationLibraryCategories.Asset => $"object type {specification.ObjectType}; colour {specification.Colour}; era {specification.Era}",
            _ => string.Empty
        };
}

public static class IllustrationStarterBatchDefinition
{
    public static IReadOnlyList<IllustrationGenerationSpecification> All { get; } =
    [
        Character("char-young-adult-brunette-observer", "Young observer", "A young adult with dark hair, thoughtful expression and practical coat.", "three-quarter portrait with bright rim light and clear facial detail", "watchful, resilient", ["rich teal", "warm amber", "clear skin tones"]),
        Character("char-young-adult-red-haired-witness", "Red-haired witness", "A young adult with auburn hair and guarded warmth.", "face-readable portrait with alert eyes and clean background separation", "protective, uncertain", ["deep blue", "copper", "vivid rose"]),
        Character("char-middle-aged-weathered-man", "Weathered man", "A middle-aged man with lined features and controlled tension.", "close portrait, collar raised, bright directional key light", "withheld, imposing", ["graphite", "steel blue", "bright brass"]),
        Character("char-older-soft-presence", "Older remembered presence", "An older woman with gentle features and private warmth.", "clean bright portrait with soft contemporary depth of field", "warm, reflective", ["pearl grey", "fresh green", "warm ivory"]),
        Character("char-professional-investigator", "Professional investigator", "A composed adult investigator with notebook and steady focus.", "waist-up portrait, neutral background", "analytical, calm", ["navy", "cream", "oxide red"]),
        Character("char-neighbourhood-friend", "Neighbourhood friend", "A friendly neighbour figure with practical clothes and open posture.", "casual portrait in corridor light", "helpful, cautious", ["sage", "denim", "soft yellow"]),
        Character("char-family-relative", "Family relative", "A relative figure carrying old emotional context.", "portrait with clean domestic background separation", "familial, layered", ["plum", "fresh moss", "paper white"]),
        Character("char-quiet-antagonist", "Quiet antagonist", "A controlled figure whose stillness suggests pressure.", "bright-edged portrait with visible facial structure and crisp outline", "tense, unreadable", ["ink black", "deep green", "brass"]),
        Character("char-young-helper", "Young helper", "A younger helper figure with attentive expression.", "bright side-lit portrait", "curious, supportive", ["sky blue", "warm grey", "coral"]),
        Character("char-authorial-voice", "Recorded voice", "A character represented as memory and voice rather than direct presence.", "portrait dissolving into abstract sound-like light", "intimate, distant", ["indigo", "silver", "warm white"]),
        Character("char-archival-contact", "Archival contact", "An older archive or records contact with patient authority.", "portrait with shelves suggested behind and a bright readable face", "knowledgeable, reserved", ["warm sienna", "slate blue", "bright gold"]),
        Character("char-unknown-figure", "Unknown figure", "A partly obscured person used for unresolved identity.", "silhouette with soft facial detail, no menace caricature", "ambiguous, suspenseful", ["deep blue", "smoke grey", "amber"]),
        Location("loc-estate-house-exterior", "Estate house exterior", "An old house or converted estate building with an intriguing entrance.", "wide square view with readable architecture, glowing windows and clear path", "quiet, secretive", ["clear navy", "window gold", "wet stone highlights"]),
        Location("loc-small-flat-interior", "Small flat interior", "A modest lived-in flat with signs of careful searching.", "room corner with desk, lamp and disturbed papers, clean readable light", "intimate, investigative", ["fresh green", "warm paper", "clear blue"]),
        Location("loc-laundry-utility-room", "Laundry utility room", "A utility or laundry room with domestic machinery.", "central washer, small window, practical clutter, bright subject separation", "private, compressed", ["clean blue", "ceramic white", "rust accent"]),
        Location("loc-narrow-stairwell", "Narrow stairwell", "A cramped stairwell or landing inside an older building.", "looking down the stairs with rail shadows", "pressured, transitional", ["slate", "old cream", "sodium amber"]),
        Location("loc-wet-car-park", "Wet car park", "A small wet car park with one clear exit route.", "low view of wet tarmac with vivid reflections and bright readable lighting", "exposed, tense", ["asphalt grey", "red reflection", "clear blue"]),
        Location("loc-domestic-desk", "Domestic desk", "A desk or table where clues are assembled.", "top-down three-quarter view of desk surface", "focused, revelatory", ["walnut", "paper white", "lamp gold"]),
        Asset("asset-folded-letter", "Folded letter", "A folded handwritten-looking letter as generic evidence.", "single bright object on table, envelope nearby, no readable text", "decisive, delicate", ["paper cream", "warm gold", "clear blue"]),
        Asset("asset-worn-notebook", "Worn notebook", "A worn notebook with a torn page edge.", "notebook angled on clean contrasting surface, blank pages", "investigative, intimate", ["rich leather", "cream paper", "clear blue"]),
        Asset("asset-red-classic-car", "Red classic car", "A small red classic sports car used as a reusable vehicle asset.", "three-quarter view with wet reflections, no badges or plates", "charged, mobile", ["deep red", "chrome grey", "night blue"]),
        Asset("asset-old-keys", "Old keys", "A small bunch of old keys on a tag.", "close object study with strong silhouette", "access, possibility", ["brass", "graphite", "warm white"]),
        Asset("asset-rucksack", "Rucksack", "A moved rucksack or soft bag.", "bag on floor near doorway with crisp outline", "recent movement, absence", ["canvas green", "warm tan", "clean blue"]),
        Asset("asset-phone-recorder", "Phone recording", "A phone or recorder playing an audio message.", "device glowing on table, no visible text", "intimate, urgent", ["black glass", "blue glow", "warm tabletop"]),
        Asset("asset-torn-page", "Torn page", "A torn page fragment with no readable writing.", "paper fragment under bright warm lamplight", "partial evidence", ["paper white", "warm gold", "ink blue"]),
        Asset("asset-locked-box", "Locked box", "A small locked keepsake or document box.", "box with latch in soft light", "contained secret", ["dark wood", "brass", "cool grey"]),
        Asset("asset-photo-print", "Photo print", "A generic photograph print with indistinct figures.", "photo on desk with intentionally unreadable detail and bright edge separation", "memory, proof", ["vintage colour", "cream border", "clean graphite"]),
        Asset("asset-map-pin", "Map clue", "A simple map or route clue with no readable labels.", "folded map with pin-like marker, no text", "connection, route", ["fresh green", "paper tan", "red accent"])
    ];

    public static IReadOnlySet<string> StarterCodes { get; } = All.Select(item => item.Code).ToHashSet(StringComparer.OrdinalIgnoreCase);

    public static IllustrationStarterGenerationPlan BuildGenerationPlan(IReadOnlyList<IllustrationLibraryItem> existingItems)
    {
        var byCode = existingItems
            .Where(item => StarterCodes.Contains(item.StableCode))
            .GroupBy(item => item.StableCode, StringComparer.OrdinalIgnoreCase)
            .ToDictionary(group => group.Key, group => group.ToList(), StringComparer.OrdinalIgnoreCase);

        var characters = CountEligible(IllustrationLibraryCategories.Character);
        var locations = CountEligible(IllustrationLibraryCategories.Location);
        var assets = CountEligible(IllustrationLibraryCategories.Asset);
        return new(characters, locations, assets);

        int CountEligible(string category)
            => All.Count(specification =>
            {
                if (!string.Equals(specification.Category, category, StringComparison.Ordinal))
                {
                    return false;
                }

                if (!byCode.TryGetValue(specification.Code, out var existing))
                {
                    return true;
                }

                if (existing.Any(IsSuccessfulOrRunning))
                {
                    return false;
                }

                return existing.Any(item => item.Status is IllustrationLibraryStatuses.Planned or IllustrationLibraryStatuses.Failed);
            });
    }

    public static bool IsStarterCodeEligibleForGeneration(string code, IReadOnlyList<IllustrationLibraryItem> existingItems)
    {
        var matching = existingItems.Where(item => StarterCodes.Contains(item.StableCode) && string.Equals(item.StableCode, code, StringComparison.OrdinalIgnoreCase)).ToList();
        return matching.Count == 0
            || (!matching.Any(IsSuccessfulOrRunning) && matching.Any(item => item.Status is IllustrationLibraryStatuses.Planned or IllustrationLibraryStatuses.Failed));
    }

    private static bool IsSuccessfulOrRunning(IllustrationLibraryItem item)
        => item.Status is IllustrationLibraryStatuses.Generated or IllustrationLibraryStatuses.Approved or IllustrationLibraryStatuses.Queued or IllustrationLibraryStatuses.Generating;

    private static IllustrationGenerationSpecification Character(string code, string title, string subject, string composition, string mood, IReadOnlyList<string> palette)
        => Spec(IllustrationLibraryCategories.Character, code, title, subject, composition, mood, palette, ["character", "representative"]);

    private static IllustrationGenerationSpecification Location(string code, string title, string subject, string composition, string mood, IReadOnlyList<string> palette)
        => Spec(IllustrationLibraryCategories.Location, code, title, subject, composition, mood, palette, ["location", "representative"]);

    private static IllustrationGenerationSpecification Asset(string code, string title, string subject, string composition, string mood, IReadOnlyList<string> palette)
        => Spec(IllustrationLibraryCategories.Asset, code, title, subject, composition, mood, palette, ["asset", "representative"]);

    private static IllustrationGenerationSpecification Spec(string category, string code, string title, string subject, string composition, string mood, IReadOnlyList<string> palette, IReadOnlyList<string> tags)
    {
        var shared = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
        {
            ["libraryPurpose"] = "Story Intelligence representative identifier",
            ["privacy"] = "generic-no-manuscript-data",
            ["style"] = "editorial-stylised-realism",
            ["generationSource"] = "Starter batch"
        };

        var common = new IllustrationGenerationSpecification
        {
            Category = category,
            Code = code,
            Title = title,
            Description = "Representative visual identifier for Story Intelligence. Not canonical to any manuscript.",
            VisualSubject = subject,
            Composition = composition,
            Mood = mood,
            Palette = palette,
            MetadataTags = tags,
            Metadata = shared
        };

        return category switch
        {
            IllustrationLibraryCategories.Character => WithCharacter(common, code),
            IllustrationLibraryCategories.Location => WithLocation(common, code),
            IllustrationLibraryCategories.Asset => WithAsset(common, code),
            _ => common
        };

        static IllustrationGenerationSpecification WithCharacter(IllustrationGenerationSpecification source, string code)
            => code switch
            {
                "char-young-adult-red-haired-witness" => Copy(source, apparentAgeBand: "YoungAdult", presentation: "Feminine", hairColour: "Red", hairLength: "Long", skinTone: "Light", features: ["Freckles"], clothingEra: "1980s", clothingStyle: "Casual"),
                "char-middle-aged-weathered-man" => Copy(source, apparentAgeBand: "MiddleAged", presentation: "Masculine", hairColour: "DarkBrown", hairLength: "Short", skinTone: "LightMedium", features: ["LinedFace"], clothingEra: "1980s", clothingStyle: "SmartCasual"),
                "char-older-soft-presence" => Copy(source, apparentAgeBand: "Older", presentation: "Feminine", hairColour: "Grey", hairLength: "Short", skinTone: "Light", features: ["GentleExpression"], clothingEra: "1980s", clothingStyle: "Domestic"),
                "char-young-helper" => Copy(source, apparentAgeBand: "Teen", presentation: "Androgynous", hairColour: "Black", hairLength: "Short", skinTone: "Deep", features: ["BrightEyes"], clothingEra: "1980s", clothingStyle: "Casual"),
                "char-archival-contact" => Copy(source, apparentAgeBand: "Older", presentation: "Masculine", hairColour: "White", hairLength: "Short", skinTone: "Medium", features: ["Glasses"], clothingEra: "1980s", clothingStyle: "Professional"),
                _ => Copy(source, apparentAgeBand: "YoungAdult", presentation: "Feminine", hairColour: "Brown", hairLength: "Medium", skinTone: "Medium", features: ["ThoughtfulExpression"], clothingEra: "1980s", clothingStyle: "Casual")
            };

        static IllustrationGenerationSpecification WithLocation(IllustrationGenerationSpecification source, string code)
            => code switch
            {
                "loc-estate-house-exterior" => Copy(source, locationType: "DetachedHouse", era: "1980s", setting: "Suburban"),
                "loc-small-flat-interior" => Copy(source, locationType: "ModestFlatInterior", era: "1980s", setting: "Urban"),
                "loc-laundry-utility-room" => Copy(source, locationType: "LaundryUtilityRoom", era: "1980s", setting: "Domestic"),
                "loc-wet-car-park" => Copy(source, locationType: "CarPark", era: "1980s", setting: "Urban"),
                "loc-domestic-desk" => Copy(source, locationType: "Kitchen", era: "1980s", setting: "Domestic"),
                _ => Copy(source, locationType: "UrbanStreet", era: "1980s", setting: "Urban")
            };

        static IllustrationGenerationSpecification WithAsset(IllustrationGenerationSpecification source, string code)
            => code switch
            {
                "asset-folded-letter" => Copy(source, objectType: "Letter", colour: "Cream", era: "1980s"),
                "asset-worn-notebook" => Copy(source, objectType: "Notebook", colour: "Brown", era: "1980s"),
                "asset-red-classic-car" => Copy(source, objectType: "ClassicSportsCar", colour: "Red", era: "1970s"),
                "asset-old-keys" => Copy(source, objectType: "Keys", colour: "Brass", era: "1980s"),
                "asset-rucksack" => Copy(source, objectType: "Rucksack", colour: "Green", era: "1980s"),
                "asset-phone-recorder" => Copy(source, objectType: "Telephone", colour: "Black", era: "1980s"),
                "asset-photo-print" => Copy(source, objectType: "Photograph", colour: "FadedColour", era: "1980s"),
                "asset-locked-box" => Copy(source, objectType: "JewelleryBox", colour: "DarkWood", era: "1980s"),
                "asset-torn-page" => Copy(source, objectType: "OfficialDocument", colour: "Cream", era: "1980s"),
                _ => Copy(source, objectType: "Suitcase", colour: "Brown", era: "1980s")
            };

        static IllustrationGenerationSpecification Copy(
            IllustrationGenerationSpecification source,
            string? apparentAgeBand = null,
            string? presentation = null,
            string? hairColour = null,
            string? hairLength = null,
            string? skinTone = null,
            IReadOnlyList<string>? features = null,
            string? clothingEra = null,
            string? clothingStyle = null,
            string? locationType = null,
            string? era = null,
            string? setting = null,
            string? objectType = null,
            string? colour = null)
            => new()
            {
                Category = source.Category,
                Code = source.Code,
                Title = source.Title,
                Description = source.Description,
                VisualSubject = source.VisualSubject,
                Composition = source.Composition,
                Mood = source.Mood,
                Palette = source.Palette,
                MetadataTags = source.MetadataTags,
                Metadata = source.Metadata,
                ApparentAgeBand = apparentAgeBand,
                Presentation = presentation,
                HairColour = hairColour,
                HairLength = hairLength,
                SkinTone = skinTone,
                Features = features ?? [],
                ClothingEra = clothingEra,
                ClothingStyle = clothingStyle,
                LocationType = locationType,
                Era = era,
                Setting = setting,
                ObjectType = objectType,
                Colour = colour
            };
    }
}

public interface IIllustrationImageProvider
{
    string ProviderName { get; }
    string EffectiveModel { get; }
    bool IsConfigured { get; }
    IReadOnlyList<string> MissingConfiguration { get; }
    Task<IllustrationImageGenerationResult> GenerateAsync(IllustrationImageGenerationRequest request, CancellationToken cancellationToken);
}

public sealed class OpenAIIllustrationImageProvider(
    HttpClient httpClient,
    IOptions<StoryIntelligenceOptions> options,
    ILogger<OpenAIIllustrationImageProvider> logger) : IIllustrationImageProvider
{
    private readonly StoryIntelligenceOptions _options = options.Value;

    public string ProviderName => "OpenAI";
    public string EffectiveModel => string.IsNullOrWhiteSpace(_options.ImageGenerationModel) ? "not configured" : _options.ImageGenerationModel;
    public bool IsConfigured => MissingConfiguration.Count == 0;
    public IReadOnlyList<string> MissingConfiguration
    {
        get
        {
            var missing = new List<string>();
            if (string.IsNullOrWhiteSpace(_options.ApiKey))
            {
                missing.Add("OpenAI API key");
            }

            if (string.IsNullOrWhiteSpace(_options.ImageGenerationModel))
            {
                missing.Add("image-generation model");
            }

            return missing;
        }
    }

    public async Task<IllustrationImageGenerationResult> GenerateAsync(IllustrationImageGenerationRequest request, CancellationToken cancellationToken)
    {
        if (!IsConfigured)
        {
            return new(false, Provider: ProviderName, Model: EffectiveModel, ErrorMessage: $"OpenAI image generation is not configured. Missing: {string.Join(", ", MissingConfiguration)}.");
        }

        using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "https://api.openai.com/v1/images/generations");
        httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.ApiKey);
        httpRequest.Content = new StringContent(JsonSerializer.Serialize(new
        {
            model = _options.ImageGenerationModel,
            prompt = request.Prompt,
            size = string.IsNullOrWhiteSpace(_options.ImageGenerationSize) ? "1024x1024" : _options.ImageGenerationSize,
            n = 1
        }), Encoding.UTF8, "application/json");

        using var response = await httpClient.SendAsync(httpRequest, cancellationToken);
        var body = await response.Content.ReadAsStringAsync(cancellationToken);
        if (!response.IsSuccessStatusCode)
        {
            var error = OpenAIImageError.FromResponseBody(body);
            logger.LogWarning(
                "OpenAI image generation failed for illustration {ItemID} with HTTP {StatusCode}, error code {ErrorCode}: {ErrorMessage}",
                request.IllustrationLibraryItemID,
                (int)response.StatusCode,
                error.Code ?? "none",
                error.Message);
            return new(false, Provider: ProviderName, Model: EffectiveModel, ErrorMessage: $"Provider returned {(int)response.StatusCode}: {error.ForStorage()}");
        }

        using var document = JsonDocument.Parse(body);
        var requestId = response.Headers.TryGetValues("x-request-id", out var values) ? values.FirstOrDefault() : null;
        var b64 = document.RootElement.GetProperty("data")[0].GetProperty("b64_json").GetString();
        if (string.IsNullOrWhiteSpace(b64))
        {
            return new(false, Provider: ProviderName, Model: EffectiveModel, ProviderRequestID: requestId, ErrorMessage: "Provider response did not include image data.");
        }

        return new(true, Convert.FromBase64String(b64), "image/png", ProviderName, EffectiveModel, requestId);
    }

    private static string TrimForStorage(string value)
        => value.Length <= 900 ? value : value[..900];

    private sealed record OpenAIImageError(string Message, string? Code)
    {
        public static OpenAIImageError FromResponseBody(string body)
        {
            try
            {
                using var document = JsonDocument.Parse(body);
                if (document.RootElement.TryGetProperty("error", out var error))
                {
                    var message = error.TryGetProperty("message", out var messageElement) ? messageElement.GetString() : null;
                    var code = error.TryGetProperty("code", out var codeElement) ? codeElement.GetString() : null;
                    return new(TrimForStorage(string.IsNullOrWhiteSpace(message) ? body : message), string.IsNullOrWhiteSpace(code) ? null : TrimForStorage(code));
                }
            }
            catch (JsonException)
            {
            }

            return new(TrimForStorage(body), null);
        }

        public string ForStorage()
            => string.IsNullOrWhiteSpace(Code) ? Message : $"{Code}: {Message}";
    }
}

public interface IIllustrationLibraryStorageService
{
    IllustrationStoredImage StoreGeneratedImage(int itemId, string category, string code, byte[] imageBytes);
}

public sealed class IllustrationLibraryStorageService(IUploadStorageService uploadStorage) : IIllustrationLibraryStorageService
{
    private const int StandardMaxSize = 1200;
    private const int ThumbnailSize = 256;

    public IllustrationStoredImage StoreGeneratedImage(int itemId, string category, string code, byte[] imageBytes)
    {
        using var bitmap = SKBitmap.Decode(imageBytes) ?? throw new InvalidDataException("Generated image could not be decoded.");
        var safeCode = SafeSegment(code);
        var categoryFolder = IllustrationLibraryCategories.StorageFolder(category);
        var uploadRoot = uploadStorage.GetDirectory("story-intelligence-library", categoryFolder, safeCode);
        var version = $"{itemId}-{DateTime.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}";
        var imageFile = $"{version}.webp";
        var thumbnailFile = $"{version}-thumb.webp";
        var imagePath = Path.Combine(uploadRoot, imageFile);
        var thumbnailPath = Path.Combine(uploadRoot, thumbnailFile);

        SaveContainedWebp(bitmap, imagePath, StandardMaxSize, 84);
        SaveCroppedWebp(bitmap, thumbnailPath, ThumbnailSize, ThumbnailSize, 78);

        return new(
            uploadStorage.GetPublicPath("story-intelligence-library", categoryFolder, safeCode, imageFile),
            uploadStorage.GetPublicPath("story-intelligence-library", categoryFolder, safeCode, thumbnailFile),
            bitmap.Width,
            bitmap.Height,
            new FileInfo(imagePath).Length,
            "image/webp");
    }

    public static string SafeSegment(string value)
    {
        var builder = new StringBuilder(value.Length);
        foreach (var character in value.Trim().ToLowerInvariant())
        {
            if (char.IsLetterOrDigit(character) || character is '-')
            {
                builder.Append(character);
            }
            else if (char.IsWhiteSpace(character) || character is '_' or '.')
            {
                builder.Append('-');
            }
        }

        var result = builder.ToString().Trim('-');
        if (string.IsNullOrWhiteSpace(result) || result.Contains("..", StringComparison.Ordinal))
        {
            throw new ArgumentException("Illustration storage code is not safe.", nameof(value));
        }

        return result;
    }

    private static void SaveContainedWebp(SKBitmap bitmap, string path, int maxSize, int quality)
    {
        var scale = Math.Min(1f, maxSize / (float)Math.Max(bitmap.Width, bitmap.Height));
        var targetWidth = Math.Max(1, (int)Math.Round(bitmap.Width * scale));
        var targetHeight = Math.Max(1, (int)Math.Round(bitmap.Height * scale));
        using var resized = bitmap.Resize(new SKImageInfo(targetWidth, targetHeight), SKSamplingOptions.Default) ?? bitmap.Copy();
        using var image = SKImage.FromBitmap(resized);
        using var data = image.Encode(SKEncodedImageFormat.Webp, quality);
        using var stream = File.Open(path, FileMode.Create, FileAccess.Write, FileShare.None);
        data.SaveTo(stream);
    }

    private static void SaveCroppedWebp(SKBitmap bitmap, string path, int targetWidth, int targetHeight, int quality)
    {
        var sourceSize = Math.Min(bitmap.Width, bitmap.Height);
        var left = (bitmap.Width - sourceSize) / 2;
        var top = (bitmap.Height - sourceSize) / 2;
        using var surface = SKSurface.Create(new SKImageInfo(targetWidth, targetHeight));
        var canvas = surface.Canvas;
        canvas.Clear(SKColors.Transparent);
        canvas.DrawBitmap(
            bitmap,
            new SKRect(left, top, left + sourceSize, top + sourceSize),
            new SKRect(0, 0, targetWidth, targetHeight));
        using var image = surface.Snapshot();
        using var data = image.Encode(SKEncodedImageFormat.Webp, quality);
        using var stream = File.Open(path, FileMode.Create, FileAccess.Write, FileShare.None);
        data.SaveTo(stream);
    }
}

public interface IIllustrationGenerationRunner
{
    Task ProcessNextAsync(CancellationToken cancellationToken);
}

public sealed class IllustrationGenerationRunner(
    IIllustrationLibraryRepository repository,
    IIllustrationPromptBuilder promptBuilder,
    IIllustrationImageProvider provider,
    IIllustrationLibraryStorageService storage,
    ILogger<IllustrationGenerationRunner> logger) : IIllustrationGenerationRunner
{
    public async Task ProcessNextAsync(CancellationToken cancellationToken)
    {
        var item = await repository.ClaimNextAsync();
        if (item is null)
        {
            return;
        }

        try
        {
            var specification = JsonSerializer.Deserialize<IllustrationGenerationSpecification>(
                item.SpecificationJson ?? string.Empty,
                new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
            if (specification is null)
            {
                await repository.MarkFailedAsync(item.IllustrationLibraryItemID, "Generation specification could not be read.");
                return;
            }

            var prompt = promptBuilder.Build(specification);
            var request = new IllustrationImageGenerationRequest(item.IllustrationLibraryItemID, item.StableCode, item.Category, prompt.Prompt, prompt.TemplateVersion);
            var result = await provider.GenerateAsync(request, cancellationToken);
            if (!result.Succeeded || !result.HasImage)
            {
                await repository.MarkFailedAsync(item.IllustrationLibraryItemID, result.ErrorMessage ?? "Provider did not return an image.");
                return;
            }

            var stored = storage.StoreGeneratedImage(item.IllustrationLibraryItemID, item.Category, item.StableCode, result.ImageBytes!);
            await repository.MarkGeneratedAsync(item.IllustrationLibraryItemID, stored, result, prompt.Prompt, prompt.TemplateVersion);
        }
        catch (Exception ex) when (ex is JsonException or InvalidDataException or IOException or ArgumentException)
        {
            logger.LogWarning(ex, "Illustration generation failed for library item {ItemID}.", item.IllustrationLibraryItemID);
            await repository.MarkFailedAsync(item.IllustrationLibraryItemID, ex.Message);
        }
    }
}

public sealed class IllustrationGenerationWorker(
    IServiceScopeFactory scopeFactory,
    ILogger<IllustrationGenerationWorker> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                using var scope = scopeFactory.CreateScope();
                var runner = scope.ServiceProvider.GetRequiredService<IIllustrationGenerationRunner>();
                await runner.ProcessNextAsync(stoppingToken);
            }
            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
            {
                return;
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "Illustration generation worker failed while checking the queue.");
            }

            await timer.WaitForNextTickAsync(stoppingToken);
        }
    }
}

public interface IIllustrationLibraryService
{
    Task<IllustrationLibraryAdminViewModel> BuildAdminViewModelAsync(IllustrationLibraryFilter filter);
    Task<IllustrationLibraryActionResult> EnsureStarterPlanAsync();
    Task<IllustrationLibraryActionResult> GenerateStarterLibraryAsync(bool confirmed);
    Task<IllustrationBulkRetryResult> RequeueAllFailedAsync();
    Task<IllustrationLibraryActionResult> QueueAsync(int? illustrationLibraryItemId, string? category, bool confirmed);
    Task<IllustrationLibraryActionResult> ApproveAsync(int illustrationLibraryItemId, int userId);
    Task<IllustrationLibraryActionResult> RejectAsync(int illustrationLibraryItemId, int userId, string? reason);
    Task<IllustrationLibraryActionResult> RegenerateAsync(int illustrationLibraryItemId, bool confirmed);
    Task<IllustrationLibraryActionResult> DisableAsync(int illustrationLibraryItemId);
    Task<IllustrationLibraryActionResult> UpdateMetadataAsync(int illustrationLibraryItemId, IllustrationLibraryMetadataForm form);
    Task ApplyApprovedIllustrationsAsync(StoryIntelligenceExperiencePrototypeViewModel prototype);
}

public sealed class IllustrationLibraryService(
    IIllustrationLibraryRepository repository,
    IIllustrationPromptBuilder promptBuilder,
    IIllustrationImageProvider provider,
    IUploadStorageService uploadStorage) : IIllustrationLibraryService
{
    private const int MaxGenerationAttempts = 3;

    public async Task<IllustrationLibraryAdminViewModel> BuildAdminViewModelAsync(IllustrationLibraryFilter filter)
    {
        await repository.SupersedeRecoveredFailuresAsync();
        var items = await repository.ListAsync(filter);
        var allItems = string.IsNullOrWhiteSpace(filter.Category) && string.IsNullOrWhiteSpace(filter.Status) && string.IsNullOrWhiteSpace(filter.Search)
            ? items
            : await repository.ListAsync(new IllustrationLibraryFilter());
        var summary = await repository.GetSummaryAsync();
        var missingConfiguration = provider.MissingConfiguration;
        return new()
        {
            Filter = filter,
            Items = items,
            Summary = summary,
            StarterCharacterCount = IllustrationStarterBatchDefinition.All.Count(x => x.Category == IllustrationLibraryCategories.Character),
            StarterLocationCount = IllustrationStarterBatchDefinition.All.Count(x => x.Category == IllustrationLibraryCategories.Location),
            StarterAssetCount = IllustrationStarterBatchDefinition.All.Count(x => x.Category == IllustrationLibraryCategories.Asset),
            StarterGenerationPlan = IllustrationStarterBatchDefinition.BuildGenerationPlan(allItems),
            ProviderStatus = provider.IsConfigured
                ? $"Configured: {provider.ProviderName} {provider.EffectiveModel}"
                : $"Image generation is not configured. Missing: {string.Join(", ", missingConfiguration)}."
        };
    }

    public async Task<IllustrationLibraryActionResult> EnsureStarterPlanAsync()
    {
        var createdOrExisting = 0;
        foreach (var specification in IllustrationStarterBatchDefinition.All)
        {
            var prompt = promptBuilder.Build(specification);
            await repository.UpsertPlannedAsync(specification, prompt);
            createdOrExisting++;
        }

        return new(true, $"Starter plan is ready with {createdOrExisting:N0} reusable illustration specifications.");
    }

    public async Task<IllustrationLibraryActionResult> GenerateStarterLibraryAsync(bool confirmed)
    {
        var beforeItems = await repository.ListAsync(new IllustrationLibraryFilter());
        var beforePlan = IllustrationStarterBatchDefinition.BuildGenerationPlan(beforeItems);
        if (!confirmed)
        {
            return new(false, $"Confirm Generate Starter Library first. This will queue {beforePlan.CharactersToGenerate:N0} characters, {beforePlan.LocationsToGenerate:N0} locations and {beforePlan.AssetsToGenerate:N0} assets ({beforePlan.TotalToGenerate:N0} total).");
        }

        await EnsureStarterPlanAsync();
        var items = await repository.ListAsync(new IllustrationLibraryFilter());
        var starterItems = items
            .Where(item => IllustrationStarterBatchDefinition.StarterCodes.Contains(item.StableCode))
            .Where(item => item.Status is IllustrationLibraryStatuses.Planned or IllustrationLibraryStatuses.Failed)
            .Where(item => IllustrationStarterBatchDefinition.IsStarterCodeEligibleForGeneration(item.StableCode, items))
            .GroupBy(item => item.StableCode, StringComparer.OrdinalIgnoreCase)
            .Select(group => group.OrderBy(item => item.Status == IllustrationLibraryStatuses.Failed ? 0 : 1).ThenByDescending(item => item.UpdatedUtc).First())
            .ToList();

        var queued = 0;
        foreach (var item in starterItems)
        {
            queued += await repository.QueueAsync(item.IllustrationLibraryItemID, null);
        }

        var queuedCharacters = starterItems.Count(item => item.Category == IllustrationLibraryCategories.Character);
        var queuedLocations = starterItems.Count(item => item.Category == IllustrationLibraryCategories.Location);
        var queuedAssets = starterItems.Count(item => item.Category == IllustrationLibraryCategories.Asset);
        return new(
            true,
            queued == 0
                ? "Starter Library is already generated, queued or in progress. No new images were queued."
            : $"Starter Library generation started: {queuedCharacters:N0} characters, {queuedLocations:N0} locations and {queuedAssets:N0} assets queued ({queued:N0} total).");
    }

    public async Task<IllustrationBulkRetryResult> RequeueAllFailedAsync()
    {
        await repository.SupersedeRecoveredFailuresAsync();
        var items = await repository.ListAsync(new IllustrationLibraryFilter());
        var failed = items
            .Where(item => item.Status is IllustrationLibraryStatuses.Failed or IllustrationLibraryStatuses.Superseded)
            .ToList();
        var retryLimitReached = failed.Count(item => item.GenerationAttemptCount >= MaxGenerationAttempts);
        var queued = 0;
        var skipped = 0;
        foreach (var item in failed.Where(item => item.GenerationAttemptCount < MaxGenerationAttempts))
        {
            var queuedNow = await QueueRetryTargetAsync(item, items);
            queued += queuedNow;
            if (queuedNow == 0 && ResolveRetryTarget(item, items) is null)
            {
                skipped++;
            }
        }

        return new(queued, skipped, retryLimitReached);
    }

    public static IllustrationLibraryItem? ResolveBulkRetryTarget(IllustrationLibraryItem failedItem, IReadOnlyList<IllustrationLibraryItem> items)
        => ResolveRetryTarget(failedItem, items);

    private static IllustrationLibraryItem? ResolveRetryTarget(IllustrationLibraryItem failedItem, IReadOnlyList<IllustrationLibraryItem> items)
    {
        var child = items
            .Where(candidate =>
                candidate.ParentItemID == failedItem.IllustrationLibraryItemID
                && candidate.IsActive
                && candidate.Status is IllustrationLibraryStatuses.Planned
                    or IllustrationLibraryStatuses.Queued
                    or IllustrationLibraryStatuses.Generating
                    or IllustrationLibraryStatuses.Generated
                    or IllustrationLibraryStatuses.Approved)
            .OrderByDescending(candidate => candidate.UpdatedUtc)
            .ThenByDescending(candidate => candidate.IllustrationLibraryItemID)
            .FirstOrDefault();
        if (child is not null)
        {
            return child.Status == IllustrationLibraryStatuses.Planned ? child : null;
        }

        if (failedItem.Status == IllustrationLibraryStatuses.Superseded)
        {
            return failedItem;
        }

        var siblings = items
            .Where(candidate =>
                candidate.IllustrationLibraryItemID != failedItem.IllustrationLibraryItemID
                && candidate.IsActive
                && string.Equals(candidate.StableCode, failedItem.StableCode, StringComparison.OrdinalIgnoreCase))
            .ToList();

        if (siblings.Any(candidate => candidate.Status is IllustrationLibraryStatuses.Queued
                or IllustrationLibraryStatuses.Generating
                or IllustrationLibraryStatuses.Generated
                or IllustrationLibraryStatuses.Approved))
        {
            return null;
        }

        return siblings
            .Where(candidate => candidate.Status == IllustrationLibraryStatuses.Planned)
            .OrderByDescending(candidate => candidate.UpdatedUtc)
            .ThenByDescending(candidate => candidate.IllustrationLibraryItemID)
            .FirstOrDefault()
            ?? failedItem;
    }

    private async Task<int> QueueRetryTargetAsync(IllustrationLibraryItem source, IReadOnlyList<IllustrationLibraryItem> items)
    {
        var target = ResolveRetryTarget(source, items);
        if (target is null)
        {
            return 0;
        }

        if (target.IllustrationLibraryItemID == source.IllustrationLibraryItemID
            && source.Status == IllustrationLibraryStatuses.Superseded)
        {
            var regeneration = await repository.CreateRegenerationAsync(source.IllustrationLibraryItemID, "Created from bulk failed-image retry action.");
            return regeneration is null ? 0 : await repository.QueueAsync(regeneration.IllustrationLibraryItemID, null);
        }

        return await repository.QueueAsync(target.IllustrationLibraryItemID, null);
    }

    public static bool HasActiveBlockingSibling(IllustrationLibraryItem item, IReadOnlyList<IllustrationLibraryItem> items)
        => items.Any(candidate =>
            candidate.IllustrationLibraryItemID != item.IllustrationLibraryItemID
            && candidate.IsActive
            && string.Equals(candidate.StableCode, item.StableCode, StringComparison.OrdinalIgnoreCase)
            && candidate.Status is IllustrationLibraryStatuses.Queued
                or IllustrationLibraryStatuses.Generating
                or IllustrationLibraryStatuses.Generated
                or IllustrationLibraryStatuses.Approved);

    public async Task<IllustrationLibraryActionResult> QueueAsync(int? illustrationLibraryItemId, string? category, bool confirmed)
    {
        if (!confirmed)
        {
            return new(false, "Confirm this queued generation action first. This can create provider cost once image generation is configured.");
        }

        if (!string.IsNullOrWhiteSpace(category) && !IllustrationLibraryCategories.IsValid(category))
        {
            return new(false, "Unknown illustration category.");
        }

        if (illustrationLibraryItemId.HasValue)
        {
            var items = await repository.ListAsync(new IllustrationLibraryFilter());
            var requested = items.FirstOrDefault(item => item.IllustrationLibraryItemID == illustrationLibraryItemId.Value);
            if (requested?.Status is IllustrationLibraryStatuses.Failed or IllustrationLibraryStatuses.Superseded)
            {
                var target = ResolveRetryTarget(requested, items);
                if (target is null)
                {
                    return new(true, "This illustration already has a regeneration generated, queued or in progress.");
                }

                if (target.IllustrationLibraryItemID == requested.IllustrationLibraryItemID
                    && requested.Status == IllustrationLibraryStatuses.Superseded)
                {
                    var regeneration = await repository.CreateRegenerationAsync(requested.IllustrationLibraryItemID, "Created from admin queue action.");
                    if (regeneration is null)
                    {
                        return new(false, "This illustration is not eligible for regeneration.");
                    }

                    illustrationLibraryItemId = regeneration.IllustrationLibraryItemID;
                }
                else
                {
                    illustrationLibraryItemId = target.IllustrationLibraryItemID;
                }
            }
        }

        var affected = await repository.QueueAsync(illustrationLibraryItemId, category);
        return new(true, affected == 0 ? "No eligible planned items were queued." : $"{affected:N0} illustration item(s) queued.");
    }

    public async Task<IllustrationLibraryActionResult> ApproveAsync(int illustrationLibraryItemId, int userId)
    {
        await repository.ApproveAsync(illustrationLibraryItemId, userId);
        return new(true, "Illustration approved for library use.");
    }

    public async Task<IllustrationLibraryActionResult> RejectAsync(int illustrationLibraryItemId, int userId, string? reason)
    {
        await repository.RejectAsync(illustrationLibraryItemId, userId, reason);
        return new(true, "Illustration rejected. It can be regenerated later.");
    }

    public async Task<IllustrationLibraryActionResult> RegenerateAsync(int illustrationLibraryItemId, bool confirmed)
    {
        if (!confirmed)
        {
            return new(false, "Confirm regeneration first. This creates a new planned child item and can create provider cost once queued.");
        }

        var items = await repository.ListAsync(new IllustrationLibraryFilter());
        var existing = items
            .Where(item =>
                item.ParentItemID == illustrationLibraryItemId
                && item.IsActive
                && item.Status is IllustrationLibraryStatuses.Planned
                    or IllustrationLibraryStatuses.Queued
                    or IllustrationLibraryStatuses.Generating
                    or IllustrationLibraryStatuses.Generated
                    or IllustrationLibraryStatuses.Approved)
            .OrderByDescending(item => item.UpdatedUtc)
            .ThenByDescending(item => item.IllustrationLibraryItemID)
            .FirstOrDefault();
        if (existing is not null)
        {
            if (existing.Status == IllustrationLibraryStatuses.Planned)
            {
                var queuedExisting = await repository.QueueAsync(existing.IllustrationLibraryItemID, null);
                return queuedExisting == 0
                    ? new(true, "This regeneration is already queued or in progress.")
                    : new(true, $"Existing regeneration item {existing.IllustrationLibraryItemID:N0} queued.");
            }

            return new(true, "This illustration already has a regeneration generated, queued or in progress.");
        }

        var item = await repository.CreateRegenerationAsync(illustrationLibraryItemId, "Created from admin regeneration action.");
        if (item is null)
        {
            return new(false, "This illustration is not eligible for regeneration.");
        }

        var queued = await repository.QueueAsync(item.IllustrationLibraryItemID, null);
        return queued == 0
            ? new(false, "Regeneration was created but could not be queued.")
            : new(true, $"Regeneration item {item.IllustrationLibraryItemID:N0} created and queued.");
    }

    public async Task<IllustrationLibraryActionResult> DisableAsync(int illustrationLibraryItemId)
    {
        await repository.DisableAsync(illustrationLibraryItemId);
        return new(true, "Illustration disabled.");
    }

    public async Task<IllustrationLibraryActionResult> UpdateMetadataAsync(int illustrationLibraryItemId, IllustrationLibraryMetadataForm form)
    {
        if (string.IsNullOrWhiteSpace(form.DisplayTitle))
        {
            return new(false, "Title is required.");
        }

        await repository.UpdateMetadataAsync(illustrationLibraryItemId, form.DisplayTitle, form.ShortDescription, form.MetadataJson, form.AdminNotes);
        return new(true, "Illustration metadata updated.");
    }

    public async Task ApplyApprovedIllustrationsAsync(StoryIntelligenceExperiencePrototypeViewModel prototype)
    {
        var items = await repository.ListAsync(new IllustrationLibraryFilter());
        var lookup = items
            .Where(item => item.Status is IllustrationLibraryStatuses.Approved or IllustrationLibraryStatuses.Generated)
            .OrderBy(item => item.Status == IllustrationLibraryStatuses.Approved ? 0 : 1)
            .ThenByDescending(item => item.UpdatedUtc)
            .GroupBy(item => item.StableCode, StringComparer.OrdinalIgnoreCase)
            .ToDictionary(group => group.Key, group => group.ToList(), StringComparer.OrdinalIgnoreCase);

        foreach (var scene in prototype.Scenes)
        {
            var locationFallback = scene.Location.ImagePath;
            var locationResolution = ResolvePrototypeImage(scene.Location.Id, scene.Location.LibraryCode, locationFallback, lookup);
            scene.Location.ImageResolution = locationResolution;
            scene.Location.ImagePath = locationResolution.FinalImageUrl ?? locationFallback;

            foreach (var character in scene.Characters)
            {
                var fallback = character.ImagePath;
                var resolution = ResolvePrototypeImage(character.Id, character.LibraryCode, fallback, lookup);
                character.ImageResolution = resolution;
                character.ImagePath = resolution.FinalImageUrl ?? fallback;
            }

            foreach (var asset in scene.Assets)
            {
                var fallback = asset.ImagePath;
                var resolution = ResolvePrototypeImage(asset.Id, asset.LibraryCode, fallback, lookup);
                asset.ImageResolution = resolution;
                asset.ImagePath = resolution.FinalImageUrl ?? fallback;
            }
        }
    }

    private StoryIntelligenceExperienceImageResolution ResolvePrototypeImage(
        string entityId,
        string stableCode,
        string fallbackImageUrl,
        IReadOnlyDictionary<string, List<IllustrationLibraryItem>> lookup)
    {
        if (string.IsNullOrWhiteSpace(stableCode) || !lookup.TryGetValue(stableCode, out var candidates))
        {
            return PrototypeFallback(entityId, stableCode, fallbackImageUrl);
        }

        foreach (var candidate in candidates)
        {
            var publicUrl = ResolveExistingPublicUploadUrl(candidate.ThumbnailPath) ?? ResolveExistingPublicUploadUrl(candidate.FilePath);
            if (publicUrl is null)
            {
                continue;
            }

            return new()
            {
                EntityId = entityId,
                StableCode = stableCode,
                IllustrationLibraryItemId = candidate.IllustrationLibraryItemID,
                Status = candidate.Status,
                FinalImageUrl = publicUrl,
                FallbackImageUrl = fallbackImageUrl,
                FallbackUsed = false
            };
        }

        var firstCandidate = candidates.FirstOrDefault();
        return new()
        {
            EntityId = entityId,
            StableCode = stableCode,
            IllustrationLibraryItemId = firstCandidate?.IllustrationLibraryItemID,
            Status = firstCandidate?.Status,
            FallbackImageUrl = fallbackImageUrl,
            FallbackUsed = true
        };
    }

    private string? ResolveExistingPublicUploadUrl(string? storedPath)
    {
        var physicalPath = uploadStorage.TryResolvePublicPath(storedPath, "uploads/story-intelligence-library");
        if (physicalPath is null || !File.Exists(physicalPath))
        {
            return null;
        }

        var normalised = storedPath!.Replace('\\', '/').Trim();
        if (!normalised.StartsWith('/'))
        {
            normalised = "/" + normalised.TrimStart('/');
        }

        return EncodeUrlPath(normalised);
    }

    private static StoryIntelligenceExperienceImageResolution PrototypeFallback(string entityId, string stableCode, string fallbackImageUrl)
        => new()
        {
            EntityId = entityId,
            StableCode = stableCode,
            FallbackImageUrl = fallbackImageUrl,
            FallbackUsed = true
        };

    private static string EncodeUrlPath(string path)
        => string.Join(
            '/',
            path.Split('/', StringSplitOptions.None)
                .Select((segment, index) => index == 0 && segment.Length == 0 ? string.Empty : Uri.EscapeDataString(segment)));
}
