using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using PlotLine.Data;
using PlotLine.Models;

namespace PlotLine.Services;

public interface IStoryMemoryService
{
    Task<StoryMemoryRebuildResult> RebuildImportSessionAsync(int importSessionId, int userId, StoryMemoryRebuildOptions options, CancellationToken cancellationToken = default);
    Task ProcessSceneResultAsync(int importSessionId, int userId, int sceneResultId, StoryMemoryResolveOptions options, CancellationToken cancellationToken = default);
    Task ResolveIllustrationsAsync(int importSessionId, StoryMemoryResolveOptions options, CancellationToken cancellationToken = default);
}

public sealed class StoryMemoryService(
    IStoryIntelligenceResultRepository results,
    IStoryIntelligencePipelineRepository pipelines,
    IStoryMemoryRepository memory,
    IIllustrationLibraryRepository illustrationLibrary,
    IIllustrationPromptBuilder promptBuilder,
    IUploadStorageService uploadStorage,
    ILogger<StoryMemoryService> logger) : IStoryMemoryService
{
    private const string ProcessorVersion = "21S";
    private static readonly JsonSerializerOptions JsonOptions = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        PropertyNameCaseInsensitive = true,
        WriteIndented = false
    };

    public async Task<StoryMemoryRebuildResult> RebuildImportSessionAsync(int importSessionId, int userId, StoryMemoryRebuildOptions options, CancellationToken cancellationToken = default)
    {
        var session = await pipelines.GetByIdForUserAsync(importSessionId, userId)
            ?? throw new InvalidOperationException($"Import Session {importSessionId} could not be found for this user.");
        var runScenes = await LoadImportScenesAsync(session, cancellationToken);
        var completedScenes = runScenes.Where(item => item.Scene is not null).ToList();

        if (options.DryRun)
        {
            return new StoryMemoryRebuildResult
            {
                ImportSessionID = importSessionId,
                DryRun = true,
                SceneResultsRead = runScenes.Count,
                SceneResultsProcessed = completedScenes.Count,
                CharactersDiscovered = completedScenes.Sum(item => item.Scene!.Characters?.Count ?? 0),
                LocationsDiscovered = completedScenes.Sum(item => (item.Scene!.Locations?.Count ?? 0) + (string.IsNullOrWhiteSpace(item.Scene.Setting?.LocationName) ? 0 : 1)),
                AssetsDiscovered = completedScenes.Sum(item => item.Scene!.Assets?.Count ?? 0),
                RelationshipsDiscovered = completedScenes.Sum(item => item.Scene!.Relationships?.Count ?? 0),
                AiCalls = 0,
                Warnings = runScenes.Where(item => item.Scene is null && !string.IsNullOrWhiteSpace(item.ParseError)).Select(item => item.ParseError!).Take(20).ToList()
            };
        }

        if (options.RebuildAll)
        {
            await memory.ClearDerivedStateAsync(importSessionId);
        }

        var identityAliases = BuildCrossSceneIdentityAliases(completedScenes);
        await memory.SetImportContextAsync(importSessionId, options.AppearancePreference, options.PrimaryAgeGroup, options.StoryEra, options.StoryLocation);
        var processed = await memory.ListProcessedSceneResultIdsAsync(importSessionId, ProcessorVersion);
        var processedCount = 0;
        foreach (var scene in completedScenes)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (!options.RebuildAll && processed.Contains(scene.Result.SceneResultID))
            {
                continue;
            }

            await UpsertSceneMemoryAsync(session, scene.Result, scene.Scene!, identityAliases, cancellationToken);
            await memory.MarkSceneResultProcessedAsync(importSessionId, scene.Result.SceneResultID, ProcessorVersion, StableContentHash(scene.Result.ParsedJson));
            processedCount++;
        }

        await ResolveIllustrationsAsync(importSessionId, new StoryMemoryResolveOptions
        {
            QueueDemand = options.QueueDemand,
            MaxDemandToQueue = options.MaxDemandToQueue,
            AppearancePreference = options.AppearancePreference,
            PrimaryAgeGroup = options.PrimaryAgeGroup,
            StoryEra = options.StoryEra,
            StoryLocation = options.StoryLocation
        }, cancellationToken);

        var diagnostics = await memory.GetDiagnosticsAsync(importSessionId);
        return new StoryMemoryRebuildResult
        {
            ImportSessionID = importSessionId,
            DryRun = false,
            SceneResultsRead = runScenes.Count,
            SceneResultsProcessed = processedCount,
            CharactersDiscovered = diagnostics.CharacterCount,
            LocationsDiscovered = diagnostics.LocationCount,
            AssetsDiscovered = diagnostics.AssetCount,
            RelationshipsDiscovered = diagnostics.RelationshipCount,
            AssignmentsCreatedOrValidated = diagnostics.AssignmentCount,
            DemandsCreated = diagnostics.DemandCount,
            FallbacksRemaining = diagnostics.FallbackCount,
            AiCalls = 0
        };
    }

    public async Task ProcessSceneResultAsync(int importSessionId, int userId, int sceneResultId, StoryMemoryResolveOptions options, CancellationToken cancellationToken = default)
    {
        var session = await pipelines.GetByIdForUserAsync(importSessionId, userId);
        if (session is null)
        {
            logger.LogWarning("Story Memory skipped scene result {SceneResultId}: import session {ImportSessionId} was not visible to user {UserId}.", sceneResultId, importSessionId, userId);
            return;
        }

        var scene = (await LoadImportScenesAsync(session, cancellationToken)).FirstOrDefault(item => item.Result.SceneResultID == sceneResultId);
        if (scene is null || scene.Scene is null)
        {
            return;
        }

        await UpsertSceneMemoryAsync(session, scene.Result, scene.Scene, new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase), cancellationToken);
        await memory.MarkSceneResultProcessedAsync(importSessionId, sceneResultId, ProcessorVersion, StableContentHash(scene.Result.ParsedJson));
        var context = await memory.GetImportContextAsync(importSessionId);
        await ResolveIllustrationsAsync(importSessionId, options with
        {
            AppearancePreference = string.IsNullOrWhiteSpace(options.AppearancePreference) ? context.AppearancePreference : options.AppearancePreference,
            PrimaryAgeGroup = string.IsNullOrWhiteSpace(options.PrimaryAgeGroup) ? context.PrimaryAgeGroup : options.PrimaryAgeGroup,
            StoryEra = string.IsNullOrWhiteSpace(options.StoryEra) ? context.StoryEra : options.StoryEra,
            StoryLocation = string.IsNullOrWhiteSpace(options.StoryLocation) ? context.StoryLocation : options.StoryLocation
        }, cancellationToken);
    }

    public async Task ResolveIllustrationsAsync(int importSessionId, StoryMemoryResolveOptions options, CancellationToken cancellationToken = default)
    {
        var context = await memory.GetImportContextAsync(importSessionId);
        options = options with
        {
            AppearancePreference = context.AppearancePreference,
            PrimaryAgeGroup = FirstConfigured(context.PrimaryAgeGroup, options.PrimaryAgeGroup),
            StoryEra = FirstConfigured(context.StoryEra, options.StoryEra),
            StoryLocation = FirstConfigured(context.StoryLocation, options.StoryLocation)
        };

        var characters = await memory.ListCharactersAsync(importSessionId);
        var attributes = await memory.ListCharacterAttributesAsync(importSessionId);
        var locations = await memory.ListLocationsAsync(importSessionId);
        var assets = await memory.ListAssetsAsync(importSessionId);
        var assignments = await memory.ListAssignmentsAsync(importSessionId);
        var catalogue = (await illustrationLibrary.ListAsync(new IllustrationLibraryFilter()))
            .Where(item => item.Status is IllustrationLibraryStatuses.Approved or IllustrationLibraryStatuses.Generated)
            .Where(item => ResolveUploadUrl(item) is not null)
            .ToList();

        var usedItemIds = assignments
            .Where(item => item.IllustrationLibraryItemID.HasValue)
            .Select(item => item.IllustrationLibraryItemID!.Value)
            .ToHashSet();

        foreach (var character in characters.Where(item => !item.IsGroupEntity))
        {
            cancellationToken.ThrowIfCancellationRequested();
            var evidence = ApplyAppearancePreference(
                CharacterEvidence(character, attributes.Where(item => item.StoryMemoryCharacterID == character.StoryMemoryCharacterID)),
                character.CanonicalIdentityKey,
                options.AppearancePreference);
            evidence = ApplyPrimaryAgeContext(evidence, options.PrimaryAgeGroup);
            var existing = assignments.FirstOrDefault(item => item.EntityType == StoryMemoryEntityTypes.Character && item.EntityID == character.StoryMemoryCharacterID);
            var existingItem = existing?.IllustrationLibraryItemID is int existingId
                ? catalogue.FirstOrDefault(item => item.IllustrationLibraryItemID == existingId)
                : null;
            var existingValid = existingItem is not null && StoryIntelligenceIllustrationCompatibility
                .CharacterHardRejections(evidence, StoryIntelligenceIllustrationCompatibility.CharacterMetadataFrom(existingItem), character.IsNamed, false)
                .Count == 0;

            var candidates = catalogue
                .Where(item => item.Category == IllustrationLibraryCategories.Character)
                .Select(item => ScoreCharacter(item, evidence, character.IsNamed, usedItemIds.Contains(item.IllustrationLibraryItemID) && item.IllustrationLibraryItemID != existing?.IllustrationLibraryItemID))
                .Where(item => item.IsCompatible)
                .OrderByDescending(item => item.Score)
                .ThenBy(item => item.Item!.StableCode)
                .ToList();
            var chosen = candidates.FirstOrDefault();
            if (chosen.Item is not null)
            {
                usedItemIds.Add(chosen.Item.IllustrationLibraryItemID);
                await memory.UpsertAssignmentAsync(new StoryMemoryIllustrationAssignmentSave(
                    importSessionId,
                    StoryMemoryEntityTypes.Character,
                    character.StoryMemoryCharacterID,
                    chosen.Item.IllustrationLibraryItemID,
                    chosen.Item.StableCode,
                    "Assigned",
                    false,
                    chosen.Score,
                    Math.Min(0.99m, chosen.Score / 200m),
                    existingValid && existingItem?.IllustrationLibraryItemID == chosen.Item.IllustrationLibraryItemID
                        ? $"Retained {chosen.Item.StableCode}; durable evidence passed hard revalidation."
                        : $"Assigned {chosen.Item.StableCode}; durable Story Memory was resolved against hard character constraints.",
                    ProcessorVersion));
                continue;
            }

            var demand = await RecordDemandAsync(importSessionId, character.ProjectID, StoryMemoryEntityTypes.Character, DemandKey(character.CanonicalIdentityKey, evidence.AgeBand, evidence.Presentation, evidence.HairColour, evidence.HairLength, evidence.SkinTone), options, CharacterDemandSpecification(character, evidence, options), cancellationToken);
            await memory.UpsertAssignmentAsync(new StoryMemoryIllustrationAssignmentSave(importSessionId, StoryMemoryEntityTypes.Character, character.StoryMemoryCharacterID, demand.IllustrationLibraryItemId, demand.StableCode, demand.AssignmentStatus, true, 0, 0, demand.AssignmentReason, ProcessorVersion));
        }

        foreach (var location in locations)
        {
            cancellationToken.ThrowIfCancellationRequested();
            var requestedType = StoryIntelligenceIllustrationCompatibility.LocationType($"{location.DisplayName} {location.SemanticType} {location.Subtype}");
            var chosen = catalogue
                .Where(item => item.Category == IllustrationLibraryCategories.Location)
                .Select(item => (Item: item, Type: StoryIntelligenceIllustrationCompatibility.LocationType($"{item.StableCode} {item.DisplayTitle} {item.ShortDescription} {item.MetadataJson} {item.SpecificationJson}")))
                .Where(item => StoryIntelligenceIllustrationCompatibility.LocationCompatible(requestedType, item.Type))
                .OrderByDescending(item => string.Equals(item.Type, requestedType, StringComparison.OrdinalIgnoreCase) ? 100 : 50)
                .ThenBy(item => item.Item.StableCode)
                .FirstOrDefault();
            if (chosen.Item is not null)
            {
                await memory.UpsertAssignmentAsync(new StoryMemoryIllustrationAssignmentSave(importSessionId, StoryMemoryEntityTypes.Location, location.StoryMemoryLocationID, chosen.Item.IllustrationLibraryItemID, chosen.Item.StableCode, "Assigned", false, 100, 0.9m, $"Assigned {chosen.Item.StableCode}; location type {requestedType} matched {chosen.Type}.", ProcessorVersion));
            }
            else
            {
                var demand = await RecordDemandAsync(importSessionId, location.ProjectID, StoryMemoryEntityTypes.Location, DemandKey(location.CanonicalIdentityKey, requestedType), options, LocationDemandSpecification(location, requestedType, options), cancellationToken);
                await memory.UpsertAssignmentAsync(new StoryMemoryIllustrationAssignmentSave(importSessionId, StoryMemoryEntityTypes.Location, location.StoryMemoryLocationID, demand.IllustrationLibraryItemId, demand.StableCode, demand.AssignmentStatus, true, 0, 0, demand.AssignmentReason, ProcessorVersion));
            }
        }

        foreach (var asset in assets)
        {
            cancellationToken.ThrowIfCancellationRequested();
            var requestedType = StoryIntelligenceIllustrationCompatibility.AssetType($"{asset.DisplayName} {asset.SemanticType} {asset.Subtype}");
            var requestedColour = StoryIntelligenceIllustrationCompatibility.AssetColour($"{asset.DisplayName} {asset.Colour}");
            var chosen = catalogue
                .Where(item => item.Category == IllustrationLibraryCategories.Asset)
                .Select(item => (Item: item, Type: StoryIntelligenceIllustrationCompatibility.AssetType($"{item.StableCode} {item.DisplayTitle} {item.ShortDescription} {item.MetadataJson} {item.SpecificationJson}"), Colour: StoryIntelligenceIllustrationCompatibility.AssetColour($"{item.StableCode} {item.DisplayTitle} {item.ShortDescription} {item.MetadataJson} {item.SpecificationJson}")))
                .Where(item => StoryIntelligenceIllustrationCompatibility.AssetCompatible(requestedType, item.Type))
                .Where(item => requestedColour == "Unknown" || item.Colour == "Unknown" || string.Equals(requestedColour, item.Colour, StringComparison.OrdinalIgnoreCase))
                .OrderByDescending(item => string.Equals(item.Type, requestedType, StringComparison.OrdinalIgnoreCase) ? 100 : 50)
                .ThenByDescending(item => string.Equals(item.Colour, requestedColour, StringComparison.OrdinalIgnoreCase) ? 10 : 0)
                .ThenBy(item => item.Item.StableCode)
                .FirstOrDefault();
            if (chosen.Item is not null)
            {
                await memory.UpsertAssignmentAsync(new StoryMemoryIllustrationAssignmentSave(importSessionId, StoryMemoryEntityTypes.Asset, asset.StoryMemoryAssetID, chosen.Item.IllustrationLibraryItemID, chosen.Item.StableCode, "Assigned", false, 100, 0.9m, $"Assigned {chosen.Item.StableCode}; asset type {requestedType} matched {chosen.Type}.", ProcessorVersion));
            }
            else
            {
                var demand = await RecordDemandAsync(importSessionId, asset.ProjectID, StoryMemoryEntityTypes.Asset, DemandKey(asset.CanonicalIdentityKey, requestedType, requestedColour), options, AssetDemandSpecification(asset, requestedType, requestedColour, options), cancellationToken);
                await memory.UpsertAssignmentAsync(new StoryMemoryIllustrationAssignmentSave(importSessionId, StoryMemoryEntityTypes.Asset, asset.StoryMemoryAssetID, demand.IllustrationLibraryItemId, demand.StableCode, demand.AssignmentStatus, true, 0, 0, demand.AssignmentReason, ProcessorVersion));
            }
        }
    }

    private async Task UpsertSceneMemoryAsync(
        StoryIntelligenceBookPipelineState session,
        StoryIntelligenceSavedSceneResult result,
        SceneIntelligenceScene scene,
        IReadOnlyDictionary<string, string> identityAliases,
        CancellationToken cancellationToken)
    {
        var context = SourceSectionType(result, scene);
        var sourceSectionType = context == "Story" ? "Story" : "Authorial";
        foreach (var sceneCharacter in scene.Characters ?? [])
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (string.IsNullOrWhiteSpace(sceneCharacter.Name))
            {
                continue;
            }

            var sceneAppearanceText = CharacterText(sceneCharacter);
            var knownNames = KnownNames(sceneCharacter.Name, sceneCharacter.Aliases);
            var appearanceAliases = CharacterAppearanceExtractionService.AliasMap(sceneAppearanceText, knownNames);
            var resolvedName = CharacterAppearanceExtractionService.CanonicalName(
                ResolveIdentityAlias(sceneCharacter.Name, identityAliases),
                appearanceAliases);
            var resolvedAliases = string.Equals(resolvedName, sceneCharacter.Name, StringComparison.OrdinalIgnoreCase)
                ? sceneCharacter.Aliases
                : (sceneCharacter.Aliases ?? []).Concat([sceneCharacter.Name!]).ToList();
            var key = CharacterKey(resolvedName, resolvedAliases);
            var displayName = CanonicalCharacterDisplayName(resolvedName, resolvedAliases);
            var isGroup = StoryIntelligenceIllustrationCompatibility.IsGroupEntity(sceneCharacter.Name);
            var resolvedPovName = ResolveScenePov(scene);
            var isPrimaryPov = string.Equals(resolvedPovName, sceneCharacter.Name, StringComparison.OrdinalIgnoreCase)
                || string.Equals(resolvedPovName, resolvedName, StringComparison.OrdinalIgnoreCase)
                || (sceneCharacter.Aliases ?? []).Any(alias => string.Equals(resolvedPovName, alias, StringComparison.OrdinalIgnoreCase));
            var character = await memory.UpsertCharacterAsync(new StoryMemoryCharacterSave(
                session.StoryIntelligenceBookPipelineID,
                session.ProjectID,
                session.BookID,
                key,
                displayName,
                context,
                sourceSectionType,
                IsNamedCharacter(sceneCharacter.Name),
                isGroup,
                true,
                isPrimaryPov,
                isPrimaryPov,
                CharacterSignificance(sceneCharacter),
                result.SceneResultID));
            await memory.AddCharacterAliasAsync(character.StoryMemoryCharacterID, sceneCharacter.Name.Trim(), Normalise(sceneCharacter.Name), "Scene character name", result.SceneResultID, sceneCharacter.Confidence ?? 0.8m);
            foreach (var alias in sceneCharacter.Aliases ?? [])
            {
                await memory.AddCharacterAliasAsync(character.StoryMemoryCharacterID, alias, Normalise(alias), "Scene character alias", result.SceneResultID, sceneCharacter.Confidence ?? 0.7m);
            }

            var identityEvidence = new[] { sceneCharacter.Name, resolvedName, displayName, sceneCharacter.RoleInScene, sceneCharacter.Notes }
                .Concat(sceneCharacter.Actions ?? [])
                .Concat(sceneCharacter.Aliases ?? [])
                .Concat(ExtensionEvidence(sceneCharacter.ExtensionData))
                .Where(value => !string.IsNullOrWhiteSpace(value))
                .Select(value => value!);
            var relationshipEvidence = (scene.Relationships ?? [])
                .Where(item => string.Equals(item.CharacterA, sceneCharacter.Name, StringComparison.OrdinalIgnoreCase) || string.Equals(item.CharacterB, sceneCharacter.Name, StringComparison.OrdinalIgnoreCase))
                .Select(item => $"{item.RelationshipSignal} {item.Evidence}");
            var evidenceName = string.Equals(resolvedName, sceneCharacter.Name, StringComparison.OrdinalIgnoreCase)
                ? resolvedName
                : sceneCharacter.Name;
            var inferred = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence(evidenceName, identityEvidence, relationshipEvidence);
            await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "AgeBand", inferred.AgeBand, inferred.AgeConfidence, inferred.AgeConfidence >= 0.85m, "Deterministic evidence inference", result.SceneResultID);
            await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "Presentation", inferred.Presentation, inferred.PresentationConfidence, inferred.PresentationConfidence >= 0.8m, "Deterministic evidence inference", result.SceneResultID);
            await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "HairColour", inferred.HairColour, 0.82m, inferred.HairColour != StoryIntelligenceIllustrationCompatibility.HairColours.Unknown, "Deterministic evidence inference", result.SceneResultID);
            await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "SkinTone", inferred.SkinTone, 0.8m, inferred.SkinTone != StoryIntelligenceIllustrationCompatibility.SkinTones.Unknown, "Deterministic evidence inference", result.SceneResultID);
            await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "HairLength", inferred.HairLength, 0.72m, inferred.HairLength != "Unknown", "Deterministic evidence inference", result.SceneResultID);
            foreach (var fact in CharacterAppearanceExtractionService.Extract(sceneAppearanceText, [displayName, .. knownNames], $"SceneResultID {result.SceneResultID}"))
            {
                if (fact.AttributeType == "Alias")
                {
                    await memory.AddCharacterAliasAsync(character.StoryMemoryCharacterID, fact.NormalisedValue, Normalise(fact.NormalisedValue), fact.EvidenceFragment, result.SceneResultID, fact.Confidence);
                }
                else
                {
                    await AddKnownAttributeAsync(character.StoryMemoryCharacterID, fact.AttributeType, fact.NormalisedValue, fact.Confidence, fact.IsExplicit, fact.EvidenceFragment, result.SceneResultID);
                }
            }
        }

        foreach (var appearance in scene.CharacterAppearance ?? [])
        {
            if (string.IsNullOrWhiteSpace(appearance.CanonicalName))
            {
                continue;
            }

            var aliases = appearance.Aliases ?? [];
            var canonical = CharacterAppearanceExtractionService.CanonicalName(
                appearance.CanonicalName,
                CharacterAppearanceExtractionService.AliasMap(appearance.Evidence ?? string.Empty, [appearance.CanonicalName, .. aliases]));
            var displayName = CanonicalCharacterDisplayName(canonical, aliases);
            var character = await memory.UpsertCharacterAsync(new StoryMemoryCharacterSave(
                session.StoryIntelligenceBookPipelineID,
                session.ProjectID,
                session.BookID,
                CharacterKey(displayName, aliases),
                displayName,
                context,
                sourceSectionType,
                IsNamedCharacter(displayName),
                StoryIntelligenceIllustrationCompatibility.IsGroupEntity(displayName),
                true,
                false,
                false,
                55,
                result.SceneResultID));

            await memory.AddCharacterAliasAsync(character.StoryMemoryCharacterID, displayName, Normalise(displayName), "Structured character appearance canonical identity", result.SceneResultID, appearance.Confidence ?? 0.85m);
            foreach (var alias in aliases.Where(alias => !string.IsNullOrWhiteSpace(alias)))
            {
                await memory.AddCharacterAliasAsync(character.StoryMemoryCharacterID, alias.Trim(), Normalise(alias), "Structured character appearance alias", result.SceneResultID, appearance.Confidence ?? 0.8m);
            }

            var evidence = string.IsNullOrWhiteSpace(appearance.Evidence) ? "Structured character appearance extraction" : appearance.Evidence.Trim();
            var explicitFact = string.Equals(appearance.EvidenceKind, "explicit", StringComparison.OrdinalIgnoreCase);
            await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "AgeBand", StoryIntelligenceIllustrationCompatibility.NormalizeAgeBand(appearance.InferredAgeBand ?? appearance.ExplicitAge ?? appearance.RoleOrOccupation), appearance.Confidence ?? 0.82m, explicitFact || !string.IsNullOrWhiteSpace(appearance.ExplicitAge), evidence, result.SceneResultID);
            await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "Presentation", StoryIntelligenceIllustrationCompatibility.NormalizePresentation(appearance.Presentation ?? appearance.PronounEvidence ?? appearance.Title), appearance.Confidence ?? 0.82m, explicitFact, evidence, result.SceneResultID);
            await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "HairColour", StoryIntelligenceIllustrationCompatibility.NormalizeHairColour(appearance.HairColour), appearance.Confidence ?? 0.9m, explicitFact, evidence, result.SceneResultID);
            await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "HairLength", StoryIntelligenceIllustrationCompatibility.NormalizeHairLength(appearance.HairLength), appearance.Confidence ?? 0.86m, explicitFact, evidence, result.SceneResultID);
        }

        if (!string.IsNullOrWhiteSpace(scene.Setting?.LocationName))
        {
            await UpsertLocationAsync(session, result, context, sourceSectionType, scene.Setting.LocationName, scene.Setting.LocationType, scene.Setting.GenericRoomType, scene.Setting.ParentLocationHint, scene.Setting.Confidence);
        }

        foreach (var location in scene.Locations ?? [])
        {
            if (!string.IsNullOrWhiteSpace(location.Name))
            {
                await UpsertLocationAsync(session, result, context, sourceSectionType, location.Name, location.LocationType, location.GenericRoomType, location.ParentLocationHint, location.Confidence);
            }
        }

        foreach (var asset in scene.Assets ?? [])
        {
            if (string.IsNullOrWhiteSpace(asset.Name))
            {
                continue;
            }

            var semantic = StoryIntelligenceIllustrationCompatibility.AssetType($"{asset.Name} {asset.AssetType} {asset.Notes}");
            await memory.UpsertAssetAsync(new StoryMemoryAssetSave(
                session.StoryIntelligenceBookPipelineID,
                session.ProjectID,
                session.BookID,
                AssetKey(asset.Name),
                asset.Name.Trim(),
                semantic,
                asset.AssetType,
                StoryIntelligenceIllustrationCompatibility.AssetColour($"{asset.Name} {asset.Notes}"),
                null,
                result.SceneResultID));
        }

        foreach (var relationship in scene.Relationships ?? [])
        {
            if (string.IsNullOrWhiteSpace(relationship.CharacterA) || string.IsNullOrWhiteSpace(relationship.CharacterB))
            {
                continue;
            }

            var sourceName = ResolveIdentityAlias(relationship.CharacterA, identityAliases);
            var targetName = ResolveIdentityAlias(relationship.CharacterB, identityAliases);
            var source = await memory.UpsertCharacterAsync(new StoryMemoryCharacterSave(session.StoryIntelligenceBookPipelineID, session.ProjectID, session.BookID, CharacterKey(sourceName), CanonicalCharacterDisplayName(sourceName, null), context, sourceSectionType, IsNamedCharacter(sourceName), StoryIntelligenceIllustrationCompatibility.IsGroupEntity(sourceName), true, false, false, 40, result.SceneResultID));
            var target = await memory.UpsertCharacterAsync(new StoryMemoryCharacterSave(session.StoryIntelligenceBookPipelineID, session.ProjectID, session.BookID, CharacterKey(targetName), CanonicalCharacterDisplayName(targetName, null), context, sourceSectionType, IsNamedCharacter(targetName), StoryIntelligenceIllustrationCompatibility.IsGroupEntity(targetName), true, false, false, 40, result.SceneResultID));
            await memory.UpsertRelationshipAsync(session.StoryIntelligenceBookPipelineID, StoryMemoryEntityTypes.Character, source.StoryMemoryCharacterID, StoryMemoryEntityTypes.Character, target.StoryMemoryCharacterID, relationship.RelationshipSignal ?? "related", relationship.Confidence ?? 0.6m, !string.IsNullOrWhiteSpace(relationship.Evidence), result.SceneResultID);
        }
    }

    private async Task UpsertLocationAsync(StoryIntelligenceBookPipelineState session, StoryIntelligenceSavedSceneResult result, string context, string sourceSectionType, string name, string? locationType, string? genericRoomType, string? parent, decimal? confidence)
    {
        var semantic = StoryIntelligenceIllustrationCompatibility.LocationType($"{name} {locationType} {genericRoomType}");
        await memory.UpsertLocationAsync(new StoryMemoryLocationSave(session.StoryIntelligenceBookPipelineID, session.ProjectID, session.BookID, LocationKey(name), name.Trim(), semantic, genericRoomType ?? locationType, InferInteriorExterior(semantic), result.SceneResultID));
    }

    private async Task AddKnownAttributeAsync(int characterId, string attributeType, string value, decimal confidence, bool isExplicit, string evidence, int sceneResultId)
    {
        if (string.IsNullOrWhiteSpace(value) || string.Equals(value, "Unknown", StringComparison.OrdinalIgnoreCase))
        {
            return;
        }

        await memory.AddCharacterAttributeAsync(characterId, attributeType, value, confidence <= 0 ? 0.6m : confidence, isExplicit, evidence, sceneResultId);
    }

    private async Task<IReadOnlyList<SceneResultWithParse>> LoadImportScenesAsync(StoryIntelligenceBookPipelineState session, CancellationToken cancellationToken)
    {
        var runs = (await results.ListRunsAsync())
            .Where(run => run.ProjectID == session.ProjectID && run.BookID == session.BookID)
            .Select(run => new StoryIntelligenceSavedRun
            {
                StoryIntelligenceRunID = run.StoryIntelligenceRunID,
                UserID = run.UserID,
                ProjectID = run.ProjectID,
                ProjectTitle = run.ProjectTitle,
                BookID = run.BookID,
                BookTitle = run.BookTitle,
                ChapterID = run.ChapterID,
                ChapterNumber = run.ChapterNumber,
                Status = run.Status,
                SourceType = run.SourceType,
                CreatedUtc = run.CreatedUtc,
                UpdatedUtc = run.CreatedUtc
            })
            .ToList();

        var items = new List<SceneResultWithParse>();
        foreach (var run in runs.OrderBy(item => item.ChapterNumber ?? decimal.MaxValue).ThenBy(item => item.StoryIntelligenceRunID))
        {
            cancellationToken.ThrowIfCancellationRequested();
            var sceneResults = await results.ListSceneResultsAsync(run.StoryIntelligenceRunID);
            items.AddRange(sceneResults.Select(item => new SceneResultWithParse(run, item, TryReadScene(item, out var error), error)));
        }

        return items
            .OrderBy(item => item.Run.ChapterNumber ?? decimal.MaxValue)
            .ThenBy(item => item.Result.TemporarySceneNumber)
            .ThenBy(item => item.Result.SceneResultID)
            .ToList();
    }

    private static SceneIntelligenceScene? TryReadScene(StoryIntelligenceSavedSceneResult result, out string? safeError)
    {
        safeError = null;
        try
        {
            if (!string.IsNullOrWhiteSpace(result.ParsedJson))
            {
                var parsed = JsonSerializer.Deserialize<SceneIntelligenceScene>(result.ParsedJson, JsonOptions);
                if (parsed?.SceneReference is not null || parsed?.Summary is not null || parsed?.Characters is not null)
                {
                    return parsed;
                }

                using var document = JsonDocument.Parse(result.ParsedJson);
                if (document.RootElement.TryGetProperty("parsedScene", out var parsedScene))
                {
                    return parsedScene.Deserialize<SceneIntelligenceScene>(JsonOptions);
                }
            }

            if (!string.IsNullOrWhiteSpace(result.OutputTextJson))
            {
                return JsonSerializer.Deserialize<SceneIntelligenceScene>(result.OutputTextJson, JsonOptions);
            }
        }
        catch (JsonException ex)
        {
            safeError = $"SceneResultID {result.SceneResultID} could not be parsed: {ex.Message}";
        }

        return null;
    }

    private static CharacterEvidenceProfile CharacterEvidence(StoryMemoryCharacter character, IEnumerable<StoryMemoryCharacterAttribute> attributes)
        => ResolveCharacterEvidenceFromAttributes(character.DisplayName, attributes);

    private static CharacterEvidenceProfile ApplyAppearancePreference(CharacterEvidenceProfile evidence, string identityKey, string preference)
    {
        if (string.Equals(evidence.HairColour, StoryIntelligenceIllustrationCompatibility.HairColours.Red, StringComparison.OrdinalIgnoreCase)
            && string.Equals(evidence.SkinTone, StoryIntelligenceIllustrationCompatibility.SkinTones.Unknown, StringComparison.OrdinalIgnoreCase))
        {
            return evidence with { SkinTone = StoryIntelligenceIllustrationCompatibility.SkinTones.Light };
        }

        if (!string.Equals(evidence.SkinTone, StoryIntelligenceIllustrationCompatibility.SkinTones.Unknown, StringComparison.OrdinalIgnoreCase))
        {
            return evidence;
        }

        var skinTone = preference switch
        {
            StoryMemoryAppearancePreferences.PredominantlyLight => StoryIntelligenceIllustrationCompatibility.SkinTones.Light,
            StoryMemoryAppearancePreferences.PredominantlyMedium => StoryIntelligenceIllustrationCompatibility.SkinTones.Medium,
            StoryMemoryAppearancePreferences.PredominantlyDark => StoryIntelligenceIllustrationCompatibility.SkinTones.Dark,
            StoryMemoryAppearancePreferences.Balanced => BalancedSkinTone(identityKey),
            StoryMemoryAppearancePreferences.CustomMix => BalancedSkinTone(identityKey),
            _ => StoryIntelligenceIllustrationCompatibility.SkinTones.Unknown
        };

        return string.Equals(skinTone, StoryIntelligenceIllustrationCompatibility.SkinTones.Unknown, StringComparison.OrdinalIgnoreCase)
            ? evidence
            : evidence with { SkinTone = skinTone };
    }

    private static CharacterEvidenceProfile ApplyPrimaryAgeContext(CharacterEvidenceProfile evidence, string primaryAgeGroup)
    {
        var contextAge = StoryIntelligenceIllustrationCompatibility.NormalizeAgeBand(primaryAgeGroup);
        if (contextAge == StoryIntelligenceIllustrationCompatibility.AgeBands.Unknown)
        {
            return evidence;
        }

        return evidence.AgeBand is StoryIntelligenceIllustrationCompatibility.AgeBands.Unknown
            or StoryIntelligenceIllustrationCompatibility.AgeBands.Adult
            ? evidence with { AgeBand = contextAge, AgeConfidence = Math.Max(evidence.AgeConfidence, 0.74m) }
            : evidence;
    }

    private static string BalancedSkinTone(string key)
    {
        var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(key));
        return (bytes[0] % 3) switch
        {
            0 => StoryIntelligenceIllustrationCompatibility.SkinTones.Light,
            1 => StoryIntelligenceIllustrationCompatibility.SkinTones.Medium,
            _ => StoryIntelligenceIllustrationCompatibility.SkinTones.Dark
        };
    }

    public static CharacterEvidenceProfile ResolveCharacterEvidenceFromAttributes(string displayName, IEnumerable<StoryMemoryCharacterAttribute> attributes)
    {
        var attributeList = attributes
            .Where(item => !string.IsNullOrWhiteSpace(item.AttributeType) && !string.IsNullOrWhiteSpace(item.NormalisedValue))
            .ToList();

        var inferred = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence(
            displayName,
            [displayName],
            []);
        var age = PickAttribute(attributeList, "AgeBand", inferred.AgeBand, inferred.AgeConfidence);
        var presentation = PickAttribute(attributeList, "Presentation", inferred.Presentation, inferred.PresentationConfidence);
        var hairColour = PickAttribute(attributeList, "HairColour", inferred.HairColour, 0.1m);
        var skinTone = PickAttribute(attributeList, "SkinTone", inferred.SkinTone, 0.1m);
        var hairLength = PickAttribute(attributeList, "HairLength", inferred.HairLength, 0.1m);
        if (string.Equals(hairColour.Value, StoryIntelligenceIllustrationCompatibility.HairColours.Red, StringComparison.OrdinalIgnoreCase)
            && string.Equals(skinTone.Value, StoryIntelligenceIllustrationCompatibility.SkinTones.Unknown, StringComparison.OrdinalIgnoreCase))
        {
            skinTone = (StoryIntelligenceIllustrationCompatibility.SkinTones.Light, Math.Max(skinTone.Confidence, 0.82m));
        }

        return new(
            age.Value,
            presentation.Value,
            hairColour.Value,
            skinTone.Value,
            hairLength.Value,
            "Unknown",
            "Unknown",
            age.Confidence,
            presentation.Confidence,
            inferred.RelationshipConfidence,
            inferred.NameConfidence,
            inferred.PronounConfidence,
            inferred.Warnings);
    }

    private static (string Value, decimal Confidence) PickAttribute(
        IReadOnlyList<StoryMemoryCharacterAttribute> attributes,
        string attributeType,
        string fallback,
        decimal fallbackConfidence)
    {
        var selected = attributes
            .Where(item => string.Equals(item.AttributeType, attributeType, StringComparison.OrdinalIgnoreCase))
            .OrderByDescending(item => item.IsExplicit)
            .ThenByDescending(item => item.Confidence)
            .ThenByDescending(item => item.SourceSceneResultID ?? 0)
            .FirstOrDefault();
        return selected is null
            ? (fallback, fallbackConfidence)
            : (selected.NormalisedValue, selected.Confidence);
    }

    private static IReadOnlyDictionary<string, string> BuildCrossSceneIdentityAliases(IReadOnlyList<SceneResultWithParse> scenes)
    {
        var aliases = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        var ordered = scenes
            .Where(item => item.Scene is not null)
            .OrderBy(item => item.Run.StoryIntelligenceRunID)
            .ThenBy(item => item.Result.TemporarySceneNumber)
            .ThenBy(item => item.Result.SceneResultID)
            .ToList();

        for (var i = 0; i < ordered.Count - 1; i++)
        {
            var current = ordered[i];
            var next = ordered[i + 1];
            if (current.Run.StoryIntelligenceRunID != next.Run.StoryIntelligenceRunID
                || current.Scene is null
                || next.Scene is null)
            {
                continue;
            }

            var descriptive = (current.Scene.Characters ?? [])
                .Where(character => IsDescriptiveRedHairedGirl(character.Name))
                .Where(character => character.MentionedOnly != true)
                .ToList();
            if (descriptive.Count == 0)
            {
                continue;
            }

            var revealed = (next.Scene.Characters ?? [])
                .Where(character => IsSimpleNamedPerson(character.Name))
                .Where(character => CharacterText(character).Contains("returns", StringComparison.OrdinalIgnoreCase)
                    || CharacterText(character).Contains("greeting", StringComparison.OrdinalIgnoreCase)
                    || CharacterText(character).Contains("introduces", StringComparison.OrdinalIgnoreCase)
                    || CharacterText(character).Contains("bar", StringComparison.OrdinalIgnoreCase)
                    || CharacterText(character).Contains("friend", StringComparison.OrdinalIgnoreCase))
                .OrderByDescending(character => character.Confidence ?? 0)
                .FirstOrDefault();
            if (revealed?.Name is null)
            {
                continue;
            }

            foreach (var character in descriptive)
            {
                aliases[Normalise(character.Name)] = revealed.Name.Trim();
                foreach (var alias in character.Aliases ?? [])
                {
                    if (!string.IsNullOrWhiteSpace(alias))
                    {
                        aliases[Normalise(alias)] = revealed.Name.Trim();
                    }
                }
            }
        }

        foreach (var character in ordered.SelectMany(item => item.Scene?.Characters ?? []))
        {
            var evidence = CharacterText(character);
            var localAliases = CharacterAppearanceExtractionService.AliasMap(evidence, KnownNames(character.Name, character.Aliases));
            foreach (var alias in localAliases)
            {
                aliases[alias.Key] = alias.Value;
            }
        }

        return aliases;
    }

    private static string ResolveIdentityAlias(string? name, IReadOnlyDictionary<string, string> identityAliases)
    {
        var normalised = Normalise(name);
        return normalised.Length > 0 && identityAliases.TryGetValue(normalised, out var resolved)
            ? resolved
            : name ?? string.Empty;
    }

    private static bool IsDescriptiveRedHairedGirl(string? name)
    {
        var normalised = Normalise(name);
        var clean = normalised.Replace('-', ' ');
        return clean.Contains("red haired girl", StringComparison.OrdinalIgnoreCase)
            || clean.Contains("girl in red", StringComparison.OrdinalIgnoreCase);
    }

    private static bool IsSimpleNamedPerson(string? name)
    {
        var clean = (name ?? string.Empty).Trim();
        if (clean.Length == 0 || !char.IsUpper(clean[0]))
        {
            return false;
        }

        var normalised = Normalise(clean);
        if (StoryIntelligenceIllustrationCompatibility.IsGroupEntity(clean)
            || normalised.Contains("girl", StringComparison.Ordinal)
            || normalised.Contains("woman", StringComparison.Ordinal)
            || normalised.Contains("man", StringComparison.Ordinal)
            || normalised.Contains("narrator", StringComparison.Ordinal))
        {
            return false;
        }

        return normalised.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length <= 2;
    }

    private static string CanonicalCharacterDisplayName(string? name, IEnumerable<string>? aliases)
    {
        var clean = (name ?? string.Empty).Trim();
        var known = KnownNames(clean, aliases);
        var aliasMap = CharacterAppearanceExtractionService.AliasMap(string.Empty, known);
        var canonical = CharacterAppearanceExtractionService.CanonicalName(clean, aliasMap);
        return string.IsNullOrWhiteSpace(canonical) ? clean : canonical;
    }

    private static IReadOnlyList<string> KnownNames(string? name, IEnumerable<string>? aliases)
        => (aliases ?? [])
            .Prepend(name ?? string.Empty)
            .Where(value => !string.IsNullOrWhiteSpace(value))
            .Select(value => value.Trim())
            .Distinct(StringComparer.OrdinalIgnoreCase)
            .ToList();

    private static string CharacterText(SceneIntelligenceCharacter character)
        => string.Join(' ', new[]
            {
                character.Name,
                character.RoleInScene,
                character.Notes,
                string.Join(' ', character.Aliases ?? []),
                string.Join(' ', character.Actions ?? [])
            }
            .Where(value => !string.IsNullOrWhiteSpace(value)));

    private (IllustrationLibraryItem? Item, int Score, bool IsCompatible) ScoreCharacter(IllustrationLibraryItem item, CharacterEvidenceProfile evidence, bool isNamed, bool alreadyUsed)
    {
        var metadata = StoryIntelligenceIllustrationCompatibility.CharacterMetadataFrom(item);
        var rejections = StoryIntelligenceIllustrationCompatibility.CharacterHardRejections(evidence, metadata, isNamed, alreadyUsed);
        if (rejections.Count > 0)
        {
            return (item, 0, false);
        }

        var score = 25;
        if (string.Equals(evidence.AgeBand, metadata.AgeBand, StringComparison.OrdinalIgnoreCase)) score += 50;
        if (string.Equals(evidence.Presentation, metadata.Presentation, StringComparison.OrdinalIgnoreCase)) score += 100;
        if (string.Equals(evidence.HairColour, metadata.HairColour, StringComparison.OrdinalIgnoreCase)) score += 20;
        if (string.Equals(evidence.SkinTone, metadata.SkinTone, StringComparison.OrdinalIgnoreCase)) score += 10;
        if (item.Status == IllustrationLibraryStatuses.Approved) score += 10;
        return (item, score, true);
    }

    private string? ResolveUploadUrl(IllustrationLibraryItem item)
        => ResolveExistingPublicUploadUrl(item.ThumbnailPath) ?? ResolveExistingPublicUploadUrl(item.FilePath);

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

    private async Task<DemandResolution> RecordDemandAsync(
        int importSessionId,
        int projectId,
        string entityType,
        string archetypeKey,
        StoryMemoryResolveOptions options,
        IllustrationGenerationSpecification specification,
        CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();
        var recorded = await memory.UpsertDemandAsync(new StoryMemoryIllustrationDemandSave(importSessionId, projectId, entityType, archetypeKey, 1, "Recorded", null, null));
        if (!options.QueueDemand)
        {
            return DemandResolution.Recorded();
        }

        if (recorded.QueuedIllustrationLibraryItemID.HasValue || recorded.GeneratedIllustrationLibraryItemID.HasValue)
        {
            var existingId = recorded.GeneratedIllustrationLibraryItemID ?? recorded.QueuedIllustrationLibraryItemID;
            var existingItem = (await illustrationLibrary.ListAsync(new IllustrationLibraryFilter()))
                .FirstOrDefault(item => item.IllustrationLibraryItemID == existingId);
            if (existingItem is not null
                && existingItem.IsActive
                && string.Equals(existingItem.StableCode, specification.Code, StringComparison.OrdinalIgnoreCase)
                && existingItem.Status is IllustrationLibraryStatuses.Planned
                    or IllustrationLibraryStatuses.Queued
                    or IllustrationLibraryStatuses.Generating
                    or IllustrationLibraryStatuses.Generated
                    or IllustrationLibraryStatuses.Approved)
            {
                return new(existingId, specification.Code, "DemandQueued", $"Demand already has queued illustration item {existingId:N0}.");
            }
        }

        if (options.MaxDemandToQueue is int maxDemandToQueue && maxDemandToQueue > 0)
        {
            var categoryCap = specification.Category == IllustrationLibraryCategories.Character
                ? maxDemandToQueue
                : Math.Max(1, maxDemandToQueue / 3);
            var queuedDemandItems = (await illustrationLibrary.ListAsync(new IllustrationLibraryFilter()))
                .Count(item => string.Equals(item.Category, specification.Category, StringComparison.OrdinalIgnoreCase)
                    && item.IsActive
                    && item.StableCode.StartsWith("demand-", StringComparison.OrdinalIgnoreCase)
                    && item.Status is IllustrationLibraryStatuses.Planned
                        or IllustrationLibraryStatuses.Queued
                        or IllustrationLibraryStatuses.Generating
                        or IllustrationLibraryStatuses.Generated
                        or IllustrationLibraryStatuses.Approved);
            if (queuedDemandItems >= categoryCap)
            {
                return new(null, null, "DemandRecorded", $"No compatible illustration exists; demand recorded but {specification.Category.ToLowerInvariant()} queue cap {categoryCap:N0} has been reached.");
            }
        }

        var prompt = promptBuilder.Build(specification);
        var planned = await illustrationLibrary.UpsertPlannedAsync(specification, prompt);
        await illustrationLibrary.QueueAsync(planned.IllustrationLibraryItemID, null);
        var queued = await memory.UpsertDemandAsync(new StoryMemoryIllustrationDemandSave(
            importSessionId,
            projectId,
            entityType,
            archetypeKey,
            1,
            "Queued",
            planned.IllustrationLibraryItemID,
            null));
        return new(queued.QueuedIllustrationLibraryItemID ?? planned.IllustrationLibraryItemID, planned.StableCode, "DemandQueued", $"No compatible illustration exists; queued demand-generated library item {planned.StableCode}.");
    }

    private static IllustrationGenerationSpecification CharacterDemandSpecification(StoryMemoryCharacter character, CharacterEvidenceProfile evidence, StoryMemoryResolveOptions options)
    {
        var code = DemandStableCode(StoryMemoryEntityTypes.Character, character.CanonicalIdentityKey, evidence.AgeBand, evidence.Presentation, evidence.HairColour, evidence.HairLength, evidence.SkinTone);
        var age = UnknownToDefault(evidence.AgeBand, NormaliseAgeContext(options.PrimaryAgeGroup));
        var presentation = UnknownToDefault(evidence.Presentation, "Androgynous");
        var hairColour = UnknownToDefault(evidence.HairColour, "Brown");
        var hairLength = UnknownToDefault(evidence.HairLength, "Medium");
        var skinTone = UnknownToDefault(evidence.SkinTone, "Medium");
        var era = UnknownToDefault(options.StoryEra, "1980s");
        var location = UnknownToDefault(options.StoryLocation, "UK");
        var skinInstruction = SkinToneInstruction(skinTone);
        var agePrompt = AgePromptText(age);
        return new IllustrationGenerationSpecification
        {
            Category = IllustrationLibraryCategories.Character,
            Code = code,
            Title = $"{age} {presentation} character",
            Description = "Reusable character portrait generated from durable Story Memory appearance evidence. Not canonical to a specific manuscript.",
            VisualSubject = $"A {agePrompt} {presentation.ToLowerInvariant()} person from {era} {location} with {hairLength.ToLowerInvariant()} {hairColour.ToLowerInvariant()} hair. Skin tone requirement: {skinInstruction}.",
            Composition = "single head-and-shoulders portrait with clear facial lighting, neutral story background, and strong thumbnail readability",
            Mood = "present, natural, story-aware",
            ApparentAgeBand = age,
            Presentation = presentation,
            HairColour = hairColour,
            HairLength = hairLength,
            SkinTone = skinTone,
            ClothingEra = era,
            ClothingStyle = $"{era} {location} everyday naturalistic",
            Palette = ["clear blue", "warm amber", "natural skin tones"],
            MetadataTags = ["character", "story-memory-demand"],
            Metadata = DemandMetadata("Character", character.CanonicalIdentityKey, options)
        };
    }

    private static IllustrationGenerationSpecification LocationDemandSpecification(StoryMemoryLocation location, string requestedType, StoryMemoryResolveOptions options)
    {
        var code = DemandStableCode(StoryMemoryEntityTypes.Location, location.CanonicalIdentityKey, requestedType);
        var locationType = UnknownToDefault(requestedType, "UnknownInterior");
        return new IllustrationGenerationSpecification
        {
            Category = IllustrationLibraryCategories.Location,
            Code = code,
            Title = $"{SplitWords(locationType)} location",
            Description = "Reusable location illustration generated from durable Story Memory semantic type. Not canonical to a specific manuscript.",
            VisualSubject = $"A clear, reusable {SplitWords(locationType).ToLowerInvariant()} environment.",
            Composition = "one readable focal environment, no prominent people, cinematic but bright enough for a small interface thumbnail",
            Mood = "grounded, atmospheric, legible",
            LocationType = locationType,
            Era = "1980s",
            Setting = location.InteriorExterior ?? "Story setting",
            Palette = ["clear navy", "warm light", "balanced natural colour"],
            MetadataTags = ["location", "story-memory-demand"],
            Metadata = DemandMetadata("Location", location.CanonicalIdentityKey, options)
        };
    }

    private static IllustrationGenerationSpecification AssetDemandSpecification(StoryMemoryAsset asset, string requestedType, string requestedColour, StoryMemoryResolveOptions options)
    {
        var code = DemandStableCode(StoryMemoryEntityTypes.Asset, asset.CanonicalIdentityKey, requestedType, requestedColour);
        var objectType = UnknownToDefault(requestedType, "UnknownObject");
        var colour = UnknownToDefault(requestedColour, "Unknown");
        return new IllustrationGenerationSpecification
        {
            Category = IllustrationLibraryCategories.Asset,
            Code = code,
            Title = $"{SplitWords(colour)} {SplitWords(objectType)} asset".Trim(),
            Description = "Reusable asset illustration generated from durable Story Memory object type. Not canonical to a specific manuscript.",
            VisualSubject = $"A single clear {SplitWords(objectType).ToLowerInvariant()} object" + (colour == "Unknown" ? "." : $" with {colour.ToLowerInvariant()} colour cues."),
            Composition = "one dominant centred object with clean silhouette, bright contrast, no readable text or logos",
            Mood = "specific, useful, readable",
            ObjectType = objectType,
            Colour = colour,
            Era = asset.Era ?? "1980s",
            Palette = ["clean blue", "warm light", "natural object colour"],
            MetadataTags = ["asset", "story-memory-demand"],
            Metadata = DemandMetadata("Asset", asset.CanonicalIdentityKey, options)
        };
    }

    private static Dictionary<string, string> DemandMetadata(string category, string entityKey, StoryMemoryResolveOptions options)
    {
        var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
        {
            ["category"] = category,
            ["generationSource"] = "Story Memory demand",
            ["libraryPurpose"] = "Live import demand fulfilment",
            ["privacy"] = "generic-no-manuscript-data",
            ["storyMemoryEntityHash"] = ShortHash(entityKey)
        };
        Add("primaryAgeGroup", options.PrimaryAgeGroup);
        Add("storyEra", options.StoryEra);
        Add("storyLocation", options.StoryLocation);
        return metadata;

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

    private static string DemandStableCode(string entityType, params string[] parts)
        => $"demand-{StableKey(entityType)}-{ShortHash(string.Join("|", parts.Where(part => !string.IsNullOrWhiteSpace(part))))}";

    private static string ShortHash(string value)
        => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant()[..12];

    private static string UnknownToDefault(string? value, string fallback)
        => string.IsNullOrWhiteSpace(value) || string.Equals(value, "Unknown", StringComparison.OrdinalIgnoreCase)
            ? fallback
            : value.Trim();

    private static string FirstConfigured(params string?[] values)
        => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty;

    private static string NormaliseAgeContext(string? value)
    {
        var normalised = StoryIntelligenceIllustrationCompatibility.NormalizeAgeBand(value);
        return normalised == StoryIntelligenceIllustrationCompatibility.AgeBands.Unknown ? "Adult" : normalised;
    }

    private static string AgePromptText(string age)
        => age switch
        {
            StoryIntelligenceIllustrationCompatibility.AgeBands.OlderTeen => "late-teen",
            StoryIntelligenceIllustrationCompatibility.AgeBands.YoungAdult => "early-twenties",
            StoryIntelligenceIllustrationCompatibility.AgeBands.YoungTeen => "young-teen",
            StoryIntelligenceIllustrationCompatibility.AgeBands.MatureAdult => "middle-aged adult",
            _ => age.ToLowerInvariant()
        };

    private static string SkinToneInstruction(string skinTone)
        => skinTone switch
        {
            StoryIntelligenceIllustrationCompatibility.SkinTones.Light => "must have light/fair skin; do not depict dark or black skin unless explicit manuscript evidence says so",
            StoryIntelligenceIllustrationCompatibility.SkinTones.Medium => "must have medium skin tone; do not drift to dark or very pale skin",
            StoryIntelligenceIllustrationCompatibility.SkinTones.Dark => "must have dark skin tone",
            _ => "not specified"
        };

    private static string SplitWords(string? value)
    {
        var clean = UnknownToDefault(value, "Unknown");
        var builder = new StringBuilder(clean.Length + 8);
        for (var i = 0; i < clean.Length; i++)
        {
            if (i > 0 && char.IsUpper(clean[i]) && char.IsLower(clean[i - 1]))
            {
                builder.Append(' ');
            }

            builder.Append(clean[i]);
        }

        return builder.ToString();
    }

    private sealed record DemandResolution(
        int? IllustrationLibraryItemId,
        string? StableCode,
        string AssignmentStatus,
        string AssignmentReason)
    {
        public static DemandResolution Recorded()
            => new(null, null, "DemandRecorded", "No compatible illustration exists; durable demand recorded.");
    }

    private static string CharacterKey(string? name, IEnumerable<string>? aliases = null) => $"character-result-{StableKey(CanonicalCharacterIdentity(name, aliases))}";
    private static string LocationKey(string? name) => $"location-result-{StableKey(name)}";
    private static string AssetKey(string? name) => $"asset-result-{StableKey(name)}";
    private static string DemandKey(params string[] parts) => StableKey(string.Join("-", parts.Where(part => !string.IsNullOrWhiteSpace(part))));

    private static string CanonicalCharacterIdentity(string? name, IEnumerable<string>? aliases = null)
    {
        var clean = (name ?? string.Empty).Trim();
        if (clean.Length == 0) return "unknown";
        var lower = clean.ToLowerInvariant();
        var aliasList = (aliases ?? [])
            .Select(alias => (Raw: alias.Trim(), Normalised: Normalise(alias)))
            .Where(alias => alias.Normalised.Length > 0)
            .ToList();
        var normalised = Normalise(clean);
        var words = normalised.Split(' ', StringSplitOptions.RemoveEmptyEntries);
        if (words.Length == 2
            && !IsTitleWord(words[0])
            && aliasList.Any(alias => string.Equals(alias.Normalised, words[0], StringComparison.OrdinalIgnoreCase)))
        {
            return words[0];
        }

        if (words.Length == 2
            && !IsTitleWord(words[0])
            && char.IsUpper(clean[0])
            && clean.All(ch => char.IsLetter(ch) || char.IsWhiteSpace(ch) || ch == '\'' || ch == '\u2019'))
        {
            return words[0];
        }

        return lower switch
        {
            "i" or "me" or "myself" or "narrator" => "pov-character",
            "mother" or "mum" or "her mother" or "his mother" => "mother",
            "father" or "dad" or "her father" or "his father" => "father",
            _ => clean
        };
    }

    private static bool IsTitleWord(string value)
        => value is "mr" or "mrs" or "miss" or "ms" or "dr" or "doctor" or "sir" or "lady";

    private static string? ResolveScenePov(SceneIntelligenceScene scene)
    {
        var raw = (scene.PointOfView?.CharacterName ?? string.Empty).Trim();
        if (raw.Length == 0)
        {
            return null;
        }

        var normalised = Normalise(raw);
        if (normalised is not ("i" or "me" or "my" or "myself" or "narrator" or "first person narrator" or "unnamed narrator"))
        {
            return raw;
        }

        var evidence = string.Join(' ', new[]
        {
            scene.PointOfView?.Evidence,
            scene.Summary?.Short,
            scene.Summary?.Detailed,
            scene.ScenePurpose?.ObservedFunction
        }.Where(value => !string.IsNullOrWhiteSpace(value)));
        var named = (scene.Characters ?? [])
            .Where(character => !string.IsNullOrWhiteSpace(character.Name))
            .Select(character => character.Name!.Trim())
            .Where(name => Normalise(name) is not ("i" or "me" or "my" or "myself" or "narrator"))
            .Distinct(StringComparer.OrdinalIgnoreCase)
            .ToList();
        return named.FirstOrDefault(name => evidence.Contains(name, StringComparison.OrdinalIgnoreCase))
            ?? (named.Count == 1 ? named[0] : null);
    }

    private static IEnumerable<string> ExtensionEvidence(Dictionary<string, JsonElement>? extensionData)
    {
        if (extensionData is null)
        {
            yield break;
        }

        foreach (var (key, value) in extensionData)
        {
            if (!key.Contains("appearance", StringComparison.OrdinalIgnoreCase)
                && !key.Contains("description", StringComparison.OrdinalIgnoreCase)
                && !key.Contains("physical", StringComparison.OrdinalIgnoreCase)
                && !key.Contains("hair", StringComparison.OrdinalIgnoreCase)
                && !key.Contains("skin", StringComparison.OrdinalIgnoreCase)
                && !key.Contains("age", StringComparison.OrdinalIgnoreCase)
                && !key.Contains("gender", StringComparison.OrdinalIgnoreCase)
                && !key.Contains("presentation", StringComparison.OrdinalIgnoreCase))
            {
                continue;
            }

            yield return $"{key}: {value}";
        }
    }

    private static string Normalise(string? value)
        => string.Join(' ', (value ?? string.Empty).Trim().ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries));

    private static string StableKey(string? value)
    {
        var normalised = Normalise(value);
        if (normalised.Length == 0) return "unknown";
        var sb = new StringBuilder(normalised.Length);
        foreach (var character in normalised)
        {
            if (char.IsLetterOrDigit(character)) sb.Append(character);
            else if (sb.Length == 0 || sb[^1] != '-') sb.Append('-');
        }
        return sb.ToString().Trim('-');
    }

    private static string StableContentHash(string? value)
    {
        var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty));
        return Convert.ToHexString(bytes).ToLowerInvariant();
    }

    private static bool IsNamedCharacter(string? name)
        => !string.IsNullOrWhiteSpace(name)
           && !StoryIntelligenceIllustrationCompatibility.IsGroupEntity(name)
           && char.IsUpper(name.Trim()[0]);

    private static int CharacterSignificance(SceneIntelligenceCharacter character)
        => character.MentionedOnly == true ? 25 : string.Equals(character.RoleInScene, "POV", StringComparison.OrdinalIgnoreCase) ? 100 : 60;

    private static string InferInteriorExterior(string semanticType)
        => semanticType is "Road" or "CarPark" or "Beach" or "Canal" or "HouseExterior" or "StreetFurniture" ? "Exterior" : "Interior";

    private static string SourceSectionType(StoryIntelligenceSavedSceneResult result, SceneIntelligenceScene scene)
    {
        var text = $"{result.SourceLabel} {scene.Summary?.Short} {scene.ScenePurpose?.ObservedFunction}".ToLowerInvariant();
        return text.Contains("author", StringComparison.Ordinal) || text.Contains("acknowledg", StringComparison.Ordinal) || text.Contains("copyright", StringComparison.Ordinal) || text.Contains("preface", StringComparison.Ordinal)
            ? "Authorial"
            : "Story";
    }

    private sealed record SceneResultWithParse(StoryIntelligenceSavedRun Run, StoryIntelligenceSavedSceneResult Result, SceneIntelligenceScene? Scene, string? ParseError);
}
