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

namespace PlotLine.Services;

public interface IStoryIntelligenceIllustrationMatchingService
{
    Task ApplyCharacterIllustrationsAsync(int projectId, StoryIntelligenceExperiencePrototypeViewModel prototype);
    Task ApplyPersistedCharacterIllustrationsAsync(int projectId, StoryIntelligenceExperiencePrototypeViewModel prototype);
    Task ApplySupportingIllustrationDemandAsync(int projectId, StoryIntelligenceExperiencePrototypeViewModel prototype);
}

public sealed class StoryIntelligenceIllustrationMatchingService(
    IIllustrationLibraryRepository library,
    IStoryIntelligenceIllustrationMatchingRepository assignments,
    IIllustrationPromptBuilder promptBuilder,
    IUploadStorageService uploadStorage,
    ILogger<StoryIntelligenceIllustrationMatchingService> logger) : IStoryIntelligenceIllustrationMatchingService
{
    private const int SuitableScore = 48;
    private const int ReassignmentMargin = 24;
    private const int DemandGenerationThreshold = 1;
    private static readonly JsonSerializerOptions JsonOptions = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        WriteIndented = false
    };

    public async Task ApplyCharacterIllustrationsAsync(int projectId, StoryIntelligenceExperiencePrototypeViewModel prototype)
    {
        if (projectId <= 0 || prototype.Scenes.Count == 0)
        {
            return;
        }

        var catalogue = (await library.ListAsync(new IllustrationLibraryFilter { Category = IllustrationLibraryCategories.Character }))
            .Where(item => item.Status is IllustrationLibraryStatuses.Approved or IllustrationLibraryStatuses.Generated)
            .Select(CharacterCandidate.From)
            .Where(candidate => candidate is not null)
            .Cast<CharacterCandidate>()
            .Select(candidate => candidate with { PublicUrl = ResolveExistingPublicUploadUrl(candidate.Item.ThumbnailPath) ?? ResolveExistingPublicUploadUrl(candidate.Item.FilePath) })
            .Where(candidate => !string.IsNullOrWhiteSpace(candidate.PublicUrl))
            .ToList();

        if (catalogue.Count == 0)
        {
            logger.LogWarning("No generated character illustrations are available for Story Intelligence matching.");
            return;
        }

        var existingAssignments = (await assignments.ListAssignmentsAsync(projectId))
            .ToDictionary(item => item.CharacterKey, StringComparer.OrdinalIgnoreCase);
        var assignedItemIds = existingAssignments.Values
            .Where(item => item.IllustrationLibraryItemID.HasValue)
            .Select(item => item.IllustrationLibraryItemID!.Value)
            .ToHashSet();
        var duplicateExistingItemIds = existingAssignments.Values
            .Where(item => item.IllustrationLibraryItemID.HasValue)
            .GroupBy(item => item.IllustrationLibraryItemID!.Value)
            .Where(group => group.Select(item => item.CharacterKey).Distinct(StringComparer.OrdinalIgnoreCase).Count() > 1)
            .Select(group => group.Key)
            .ToHashSet();
        var retainedExistingItemIds = new HashSet<int>();
        var observations = BuildCharacterObservations(prototype);

        foreach (var observation in observations.Values.OrderByDescending(item => item.Significance).ThenBy(item => item.Name))
        {
            if (StoryIntelligenceIllustrationCompatibility.IsGroupEntity(observation.Name))
            {
                ApplyGroupDiagnostics(prototype, observation);
                continue;
            }

            var existing = existingAssignments.GetValueOrDefault(observation.CharacterKey);
            var ownExistingItemId = existing?.IllustrationLibraryItemID;
            if (ownExistingItemId.HasValue && duplicateExistingItemIds.Contains(ownExistingItemId.Value) && !retainedExistingItemIds.Add(ownExistingItemId.Value))
            {
                ownExistingItemId = null;
                existing = null;
            }

            var allScores = catalogue
                .Select(candidate => Score(
                    candidate,
                    observation,
                    assignedItemIds.Contains(candidate.Item.IllustrationLibraryItemID)
                        && candidate.Item.IllustrationLibraryItemID != ownExistingItemId))
                .OrderByDescending(score => score.Score)
                .ThenBy(score => score.IsAlreadyAssignedToAnotherSignificantCharacter)
                .ThenByDescending(score => score.Candidate.Item.Status == IllustrationLibraryStatuses.Approved)
                .ThenByDescending(score => score.Candidate.Item.UpdatedUtc)
                .ToList();

            var existingScore = existing?.IllustrationLibraryItemID is int existingItemId
                ? allScores.FirstOrDefault(score => score.Candidate.Item.IllustrationLibraryItemID == existingItemId)
                : null;
            var best = allScores
                .Where(score => score.Rejections.Count == 0)
                .FirstOrDefault(score => score.Score >= SuitableScore);

            var chosen = ChooseAssignment(observation, existing, existingScore, best);
            if (chosen is null)
            {
                var demandStatus = await RecordDemandAsync(projectId, observation, allScores);
                var fallbackCandidateDiagnostics = JsonSerializer.Serialize(allScores.Take(6).Select(MatchDiagnostics.From), JsonOptions);
                var fallbackEvidenceJson = EvidenceTraceJson(observation, existing);
                await assignments.UpsertAssignmentAsync(new StoryIntelligenceCharacterIllustrationAssignmentSave(
                    projectId,
                    observation.CharacterKey,
                    observation.Name,
                    null,
                    null,
                    0,
                    0,
                    fallbackEvidenceJson,
                    fallbackCandidateDiagnostics,
                    "No hard-compatible unused illustration exists; persisted neutral fallback and recorded library demand.",
                    null));
                ApplyDemandDiagnostics(prototype, observation, allScores, demandStatus);
                continue;
            }

            var reasonReassigned = existingScore is not null && chosen.Candidate.Item.IllustrationLibraryItemID != existing?.IllustrationLibraryItemID
                ? ReassignmentReason(observation, existingScore, chosen)
                : existing is { IllustrationLibraryItemID: null }
                    ? $"Assigned {chosen.Candidate.Item.StableCode} because generated demand or newly valid library art replaced a fallback assignment."
                    : null;
            var reason = reasonReassigned ?? (existing is null
                ? $"Assigned {chosen.Candidate.Item.StableCode} from structured character evidence."
                : $"Retained {chosen.Candidate.Item.StableCode}; existing assignment passed hard revalidation with score {chosen.Score:N0}.");
            var candidateDiagnostics = JsonSerializer.Serialize(allScores.Take(6).Select(MatchDiagnostics.From), JsonOptions);
            var evidenceJson = EvidenceTraceJson(observation, existing);

            var saved = await assignments.UpsertAssignmentAsync(new StoryIntelligenceCharacterIllustrationAssignmentSave(
                projectId,
                observation.CharacterKey,
                observation.Name,
                chosen.Candidate.Item.IllustrationLibraryItemID,
                chosen.Candidate.Item.StableCode,
                Confidence(chosen.Score),
                chosen.Score,
                evidenceJson,
                candidateDiagnostics,
                reason,
                reasonReassigned));

            assignedItemIds.Add(chosen.Candidate.Item.IllustrationLibraryItemID);
            ApplyAssignment(prototype, observation, existing, chosen, saved, reason, reasonReassigned, evidenceJson, candidateDiagnostics);
        }
    }

    public async Task ApplyPersistedCharacterIllustrationsAsync(int projectId, StoryIntelligenceExperiencePrototypeViewModel prototype)
    {
        if (projectId <= 0 || prototype.Scenes.Count == 0)
        {
            return;
        }

        var existingAssignments = (await assignments.ListAssignmentsAsync(projectId))
            .Where(item => item.IllustrationLibraryItemID.HasValue && !string.IsNullOrWhiteSpace(item.StableCode))
            .ToDictionary(item => item.CharacterKey, StringComparer.OrdinalIgnoreCase);
        if (existingAssignments.Count == 0)
        {
            return;
        }

        var catalogue = (await library.ListAsync(new IllustrationLibraryFilter { Category = IllustrationLibraryCategories.Character }))
            .Where(item => item.Status is IllustrationLibraryStatuses.Approved or IllustrationLibraryStatuses.Generated)
            .Where(item => !string.IsNullOrWhiteSpace(item.StableCode))
            .GroupBy(item => item.IllustrationLibraryItemID)
            .ToDictionary(group => group.Key, group => group.First());

        foreach (var character in prototype.Scenes.SelectMany(scene => scene.Characters))
        {
            if (!existingAssignments.TryGetValue(character.Id, out var assignment)
                || assignment.IllustrationLibraryItemID is not int itemId
                || !catalogue.TryGetValue(itemId, out var item))
            {
                continue;
            }

            var publicUrl = ResolveExistingPublicUploadUrl(item.ThumbnailPath) ?? ResolveExistingPublicUploadUrl(item.FilePath);
            if (string.IsNullOrWhiteSpace(publicUrl))
            {
                continue;
            }

            var fallbackImageUrl = string.IsNullOrWhiteSpace(character.ImageResolution.FallbackImageUrl)
                ? character.ImagePath
                : character.ImageResolution.FallbackImageUrl;
            character.LibraryCode = item.StableCode;
            character.ImagePath = publicUrl;
            character.ImageResolution = new StoryIntelligenceExperienceImageResolution
            {
                EntityId = character.Id,
                StableCode = item.StableCode,
                IllustrationLibraryItemId = item.IllustrationLibraryItemID,
                Status = item.Status,
                FinalImageUrl = publicUrl,
                FallbackImageUrl = fallbackImageUrl,
                FallbackUsed = false,
                AssignmentConfidence = assignment.MatchConfidence,
                MatchingScore = assignment.MatchingScore,
                ReasonChosen = FirstConfigured(assignment.ReasonChosen, "Replay used persisted character illustration assignment."),
                ReasonReassigned = assignment.ReasonReassigned,
                PreviousIllustration = assignment.StableCode,
                DemandStatus = "Replay read-only; demand generation disabled",
                IllustrationAllocationStatus = "Persisted replay assignment"
            };
        }
    }

    public async Task ApplySupportingIllustrationDemandAsync(int projectId, StoryIntelligenceExperiencePrototypeViewModel prototype)
    {
        if (projectId <= 0 || prototype.Scenes.Count == 0)
        {
            return;
        }

        var items = await library.ListAsync(new IllustrationLibraryFilter());
        var lookup = items
            .Where(item => item.Status is IllustrationLibraryStatuses.Approved or IllustrationLibraryStatuses.Generated)
            .GroupBy(item => item.StableCode, StringComparer.OrdinalIgnoreCase)
            .ToDictionary(group => group.Key, group => group.ToList(), StringComparer.OrdinalIgnoreCase);

        foreach (var location in prototype.Scenes.Select(scene => scene.Location))
        {
            await ApplyLocationDemandAsync(projectId, location, lookup);
        }

        foreach (var asset in prototype.Scenes.SelectMany(scene => scene.Assets))
        {
            await ApplyAssetDemandAsync(projectId, asset, lookup);
        }
    }

    private async Task ApplyLocationDemandAsync(
        int projectId,
        StoryIntelligenceExperienceLocationState location,
        IReadOnlyDictionary<string, List<IllustrationLibraryItem>> lookup)
    {
        var type = StoryIntelligenceIllustrationCompatibility.LocationType($"{location.Name} {location.Label}");
        if (type is "UnknownInterior")
        {
            location.ImageResolution.ReasonChosen ??= "Location type is unresolved; neutral location fallback retained and no finished unrelated artwork assigned.";
            location.ImageResolution.DemandStatus ??= "Not queued until location type resolves";
            return;
        }

        if (!location.ImageResolution.FallbackUsed && !string.IsNullOrWhiteSpace(location.ImageResolution.FinalImageUrl))
        {
            return;
        }

        var demandKey = $"location|{type}".ToLowerInvariant();
        var code = $"loc-demand-{StableHash(demandKey)}";
        location.LibraryCode = code;

        if (TryApplyGeneratedSupportingImage(location.Id, code, location.ImagePath, lookup, out var resolution))
        {
            location.ImageResolution = WithDemandDiagnostics(
                resolution,
                demandKey,
                "Satisfied by generated demand illustration",
                $"Applied generated {type} location illustration from demand key {demandKey}.");
            location.ImagePath = resolution.FinalImageUrl ?? location.ImagePath;
            return;
        }

        var demand = await assignments.UpsertDemandAsync(new StoryIntelligenceIllustrationDemandRequest(
            projectId,
            demandKey,
            "Unknown",
            "Unknown",
            "Unknown",
            "Unknown",
            "Unknown",
            "Unknown",
            "Unknown"));
        var queued = await QueueDemandSpecAsync(LocationDemandSpecification(code, demandKey, type));
        location.ImageResolution = WithDemandDiagnostics(
            location.ImageResolution,
            demandKey,
            queued
                ? $"Queued missing {type} location illustration ({demand.RequestCount:N0}/{DemandGenerationThreshold:N0})"
                : $"Demand recorded for missing {type} location illustration ({demand.RequestCount:N0}/{DemandGenerationThreshold:N0})",
            $"No generated compatible {type} location illustration exists; neutral typed fallback retained while reusable demand is generated.",
            code,
            queued ? "DemandQueued" : "DemandRecorded");
    }

    private async Task ApplyAssetDemandAsync(
        int projectId,
        StoryIntelligenceExperienceAssetState asset,
        IReadOnlyDictionary<string, List<IllustrationLibraryItem>> lookup)
    {
        var evidenceText = $"{asset.Name} {asset.Label}";
        var type = StoryIntelligenceIllustrationCompatibility.AssetType(evidenceText);
        if (type is "UnknownObject")
        {
            asset.ImageResolution.ReasonChosen ??= "Asset type is unresolved; neutral asset fallback retained and no unrelated finished artwork assigned.";
            asset.ImageResolution.DemandStatus ??= "Not queued until asset type resolves";
            return;
        }

        if (!asset.ImageResolution.FallbackUsed && !string.IsNullOrWhiteSpace(asset.ImageResolution.FinalImageUrl))
        {
            return;
        }

        var colour = StoryIntelligenceIllustrationCompatibility.AssetColour(evidenceText);
        var demandKey = $"asset|{type}|{colour}".ToLowerInvariant();
        var code = $"asset-demand-{StableHash(demandKey)}";
        asset.LibraryCode = code;

        if (TryApplyGeneratedSupportingImage(asset.Id, code, asset.ImagePath, lookup, out var resolution))
        {
            asset.ImageResolution = WithDemandDiagnostics(
                resolution,
                demandKey,
                "Satisfied by generated demand illustration",
                $"Applied generated {type} asset illustration from demand key {demandKey}.");
            asset.ImagePath = resolution.FinalImageUrl ?? asset.ImagePath;
            return;
        }

        var demand = await assignments.UpsertDemandAsync(new StoryIntelligenceIllustrationDemandRequest(
            projectId,
            demandKey,
            "Unknown",
            "Unknown",
            "Unknown",
            "Unknown",
            "Unknown",
            "Unknown",
            "Unknown"));
        var queued = await QueueDemandSpecAsync(AssetDemandSpecification(code, demandKey, type, colour));
        asset.ImageResolution = WithDemandDiagnostics(
            asset.ImageResolution,
            demandKey,
            queued
                ? $"Queued missing {colour.ToLowerInvariant()} {type} asset illustration ({demand.RequestCount:N0}/{DemandGenerationThreshold:N0})"
                : $"Demand recorded for missing {colour.ToLowerInvariant()} {type} asset illustration ({demand.RequestCount:N0}/{DemandGenerationThreshold:N0})",
            $"No generated compatible {type} asset illustration exists; neutral typed fallback retained while reusable demand is generated.",
            code,
            queued ? "DemandQueued" : "DemandRecorded");
    }

    private bool TryApplyGeneratedSupportingImage(
        string entityId,
        string code,
        string fallbackImageUrl,
        IReadOnlyDictionary<string, List<IllustrationLibraryItem>> lookup,
        out StoryIntelligenceExperienceImageResolution resolution)
    {
        if (lookup.TryGetValue(code, out var candidates))
        {
            foreach (var candidate in candidates)
            {
                var publicUrl = ResolveExistingPublicUploadUrl(candidate.ThumbnailPath) ?? ResolveExistingPublicUploadUrl(candidate.FilePath);
                if (publicUrl is null)
                {
                    continue;
                }

                resolution = new StoryIntelligenceExperienceImageResolution
                {
                    EntityId = entityId,
                    StableCode = code,
                    IllustrationLibraryItemId = candidate.IllustrationLibraryItemID,
                    Status = candidate.Status,
                    FinalImageUrl = publicUrl,
                    FallbackImageUrl = fallbackImageUrl,
                    FallbackUsed = false
                };
                return true;
            }
        }

        resolution = new StoryIntelligenceExperienceImageResolution();
        return false;
    }

    private static StoryIntelligenceExperienceImageResolution WithDemandDiagnostics(
        StoryIntelligenceExperienceImageResolution source,
        string demandKey,
        string demandStatus,
        string reasonChosen,
        string? stableCode = null,
        string? status = null)
        => new()
        {
            EntityId = source.EntityId,
            StableCode = stableCode ?? source.StableCode,
            IllustrationLibraryItemId = source.IllustrationLibraryItemId,
            Status = status ?? source.Status,
            FinalImageUrl = source.FinalImageUrl,
            FallbackImageUrl = source.FallbackImageUrl,
            FallbackUsed = source.FallbackUsed,
            AssignmentConfidence = source.AssignmentConfidence,
            MatchingScore = source.MatchingScore,
            RejectedCandidates = source.RejectedCandidates,
            ReasonChosen = reasonChosen,
            ReasonReassigned = source.ReasonReassigned,
            PreviousIllustration = source.PreviousIllustration,
            PreviousEvidence = source.PreviousEvidence,
            CurrentEvidence = source.CurrentEvidence,
            DemandKey = demandKey,
            EvidenceWarnings = source.EvidenceWarnings,
            DemandStatus = demandStatus,
            ResolvedPresentation = source.ResolvedPresentation,
            ResolvedAgeBand = source.ResolvedAgeBand,
            RelationshipDerivedConfidence = source.RelationshipDerivedConfidence,
            NameDerivedConfidence = source.NameDerivedConfidence,
            PronounDerivedConfidence = source.PronounDerivedConfidence,
            IllustrationReuseCount = source.IllustrationReuseCount,
            IllustrationAllocationStatus = source.IllustrationAllocationStatus
        };

    private async Task<bool> QueueDemandSpecAsync(IllustrationGenerationSpecification specification)
    {
        var prompt = promptBuilder.Build(specification);
        var item = await library.UpsertPlannedAsync(specification, prompt);
        if (item.Status is IllustrationLibraryStatuses.Queued or IllustrationLibraryStatuses.Generating or IllustrationLibraryStatuses.Generated or IllustrationLibraryStatuses.Approved)
        {
            return false;
        }

        return await library.QueueAsync(item.IllustrationLibraryItemID, null) > 0;
    }

    private MatchScore? ChooseAssignment(
        CharacterObservation observation,
        StoryIntelligenceCharacterIllustrationAssignment? existing,
        MatchScore? existingScore,
        MatchScore? best)
    {
        if (existingScore is not null && existingScore.Candidate.PublicUrl is not null)
        {
            if (existingScore.Rejections.Count > 0)
            {
                return best;
            }

            if (best is null)
            {
                return existingScore.Score >= SuitableScore ? existingScore : null;
            }

            var materiallyWrong = MateriallyWrong(existingScore.Candidate.Metadata, observation.Evidence);
            if (materiallyWrong)
            {
                return best;
            }

            if (!materiallyWrong || best.Score < existingScore.Score + ReassignmentMargin)
            {
                return existingScore.Score >= SuitableScore ? existingScore : best;
            }
        }

        if (best is not null)
        {
            return best;
        }

        return existing is null ? null : existingScore;
    }

    private static bool MateriallyWrong(CharacterIllustrationMetadata candidate, CharacterEvidence evidence)
        => KnownMismatch(evidence.AgeBand, candidate.AgeBand)
           || KnownMismatch(evidence.Presentation, candidate.Presentation)
           || KnownMismatch(evidence.HairColour, candidate.HairColour)
           || KnownMismatch(evidence.SkinTone, candidate.SkinTone);

    private static bool KnownMismatch(string value, string candidate)
        => !IsUnknown(value) && !IsUnknown(candidate) && !string.Equals(value, candidate, StringComparison.OrdinalIgnoreCase);

    private async Task<string> RecordDemandAsync(int projectId, CharacterObservation observation, IReadOnlyList<MatchScore> allScores)
    {
        var evidence = observation.Evidence;
        var request = new StoryIntelligenceIllustrationDemandRequest(
            projectId,
            DemandKey(evidence),
            evidence.AgeBand,
            evidence.Presentation,
            evidence.HairColour,
            evidence.SkinTone,
            evidence.HairLength,
            evidence.Glasses,
            evidence.FacialHair);
        var demand = await assignments.UpsertDemandAsync(request);
        var status = $"Demand recorded ({demand.RequestCount:N0}/{DemandGenerationThreshold:N0})";
        if (demand.RequestCount >= DemandGenerationThreshold)
        {
            var specification = DemandSpecification(demand);
            var prompt = promptBuilder.Build(specification);
            var item = await library.UpsertPlannedAsync(specification, prompt);
            var queued = item.Status == IllustrationLibraryStatuses.Planned
                ? await library.QueueAsync(item.IllustrationLibraryItemID, null)
                : 0;
            status = queued > 0
                ? $"Demand threshold reached; queued {item.StableCode}"
                : $"Demand threshold reached; {item.StableCode} already active";
        }

        logger.LogInformation(
            "Recorded Story Intelligence illustration demand {DemandKey} for project {ProjectId}. Request count {RequestCount}. Best score {BestScore}. Status {DemandStatus}.",
            demand.DemandKey,
            projectId,
            demand.RequestCount,
            allScores.FirstOrDefault()?.Score ?? 0,
            status);
        return status;
    }

    private static string DemandKey(CharacterEvidence evidence)
        => string.Join('|', "character", evidence.AgeBand, evidence.Presentation, evidence.HairColour, evidence.SkinTone).ToLowerInvariant();

    private static string EvidenceTraceJson(CharacterObservation observation, StoryIntelligenceCharacterIllustrationAssignment? existing)
        => JsonSerializer.Serialize(new
        {
            character = observation.Name,
            identityKey = observation.CharacterKey,
            canonicalIdentity = observation.CharacterKey,
            rawExtractedEvidence = CompactEvidence(observation.IdentityEvidence),
            sceneEvidence = CompactEvidence(observation.TextEvidence),
            relationshipEvidence = CompactEvidence(observation.RelationshipEvidence),
            mergedEvidence = CompactEvidence(observation.TextEvidence.Concat(observation.RelationshipEvidence)),
            previousAssignment = existing is null
                ? null
                : new
                {
                    existing.StableCode,
                    existing.IllustrationLibraryItemID,
                    existing.MatchConfidence,
                    existing.MatchingScore,
                    evidenceChars = existing.EvidenceJson?.Length ?? 0,
                    evidenceHash = string.IsNullOrWhiteSpace(existing.EvidenceJson) ? null : StableHash(existing.EvidenceJson)
                },
            finalMatcherEvidence = observation.Evidence,
            evidenceSource = "Story Intelligence visualisation character state",
            canonicalMergeReason = "Character state ID from canonical visualisation identity"
        }, JsonOptions);

    private static IReadOnlyList<string> CompactEvidence(IEnumerable<string> evidence)
        => evidence
            .Where(value => !string.IsNullOrWhiteSpace(value))
            .Select(value => Trim(value, 160))
            .Distinct(StringComparer.OrdinalIgnoreCase)
            .Take(16)
            .ToList();

    private static string Trim(string? value, int maxLength)
    {
        if (string.IsNullOrWhiteSpace(value))
        {
            return string.Empty;
        }

        var clean = Regex.Replace(value.Trim(), @"\s+", " ");
        return clean.Length <= maxLength ? clean : clean[..maxLength].TrimEnd() + "...";
    }

    private static IllustrationGenerationSpecification DemandSpecification(StoryIntelligenceIllustrationDemand demand)
    {
        var age = NormaliseUnknown(demand.AgeBand, "YoungAdult");
        var presentation = NormaliseUnknown(demand.Presentation, "Androgynous");
        var hairColour = NormaliseUnknown(demand.HairColour, "Brown");
        var hairLength = NormaliseUnknown(demand.HairLength, "Medium");
        var skinTone = NormaliseUnknown(demand.SkinTone, BalancedSkinToneFallback(demand.DemandKey));
        var features = new List<string> { "DemandGenerated" };
        if (string.Equals(demand.Glasses, "Yes", StringComparison.OrdinalIgnoreCase))
        {
            features.Add("Glasses");
        }

        if (!IsUnknown(demand.FacialHair))
        {
            features.Add(demand.FacialHair!);
        }

        return new IllustrationGenerationSpecification
        {
            Category = IllustrationLibraryCategories.Character,
            Code = $"char-demand-{StableHash(demand.DemandKey)}",
            Title = $"{age} {presentation.ToLowerInvariant()} character",
            Description = "Demand-created reusable character illustration for Story Intelligence matching.",
            VisualSubject = $"{age} {presentation.ToLowerInvariant()} character with {hairLength.ToLowerInvariant()} {hairColour.ToLowerInvariant()} hair. Skin tone requirement: {SkinToneInstruction(skinTone)}.",
            Composition = "clear head-and-shoulders cinematic portrait with readable face, subtle atmospheric story background, clean separation, suitable for circular cropping",
            Mood = "neutral, story-ready, emotionally readable, subdued and cinematic",
            ApparentAgeBand = age,
            Presentation = presentation,
            HairColour = hairColour,
            HairLength = hairLength,
            SkinTone = skinTone,
            ClothingEra = "Contemporary",
            ClothingStyle = "Understated reusable story wardrobe, not studio fashion styling",
            Features = features,
            Palette = ["dark navy atmosphere", "warm skin tones", "restrained amber accent"],
            MetadataTags = ["character", "demand-generated", "story-intelligence"],
            Metadata = new(StringComparer.OrdinalIgnoreCase)
            {
                ["libraryPurpose"] = "Story Intelligence demand-generated identifier",
                ["privacy"] = "generic-no-manuscript-data",
                ["style"] = "editorial-stylised-realism",
                ["styleIntent"] = "match-approved-starter-library",
                ["generationSource"] = "Demand generation",
                ["demandKey"] = demand.DemandKey,
                ["requestCount"] = demand.RequestCount.ToString("N0")
            }
        };
    }

    private static IllustrationGenerationSpecification LocationDemandSpecification(string code, string demandKey, string type)
        => new()
        {
            Category = IllustrationLibraryCategories.Location,
            Code = code,
            Title = $"{ReadableToken(type)} location",
            Description = "Demand-created reusable location illustration for Story Intelligence matching.",
            VisualSubject = $"A clear reusable {ReadableToken(type).ToLowerInvariant()} environment suitable for manuscript visualisation.",
            Composition = "single readable environment, no prominent people, strong central focal point, suitable for circular and wide crops",
            Mood = "story-ready, atmospheric but clear, visually specific rather than generic",
            LocationType = type,
            Era = "Contemporary or late twentieth century",
            Setting = type is "Road" ? "Exterior" : type is "CarPark" or "HouseExterior" ? "Exterior" : "Interior",
            Palette = ["clear blue", "warm practical light", "balanced neutral accents"],
            MetadataTags = ["location", "demand-generated", "story-intelligence"],
            Metadata = new(StringComparer.OrdinalIgnoreCase)
            {
                ["libraryPurpose"] = "Story Intelligence demand-generated location",
                ["privacy"] = "generic-no-manuscript-data",
                ["style"] = "editorial-stylised-realism",
                ["generationSource"] = "Demand generation",
                ["demandKey"] = demandKey,
                ["locationType"] = type
            }
        };

    private static IllustrationGenerationSpecification AssetDemandSpecification(string code, string demandKey, string type, string colour)
        => new()
        {
            Category = IllustrationLibraryCategories.Asset,
            Code = code,
            Title = $"{ReadableToken(colour)} {ReadableToken(type)}",
            Description = "Demand-created reusable asset illustration for Story Intelligence matching.",
            VisualSubject = colour == "Unknown"
                ? $"A clear reusable {ReadableToken(type).ToLowerInvariant()} object."
                : $"A clear reusable {colour.ToLowerInvariant()} {ReadableToken(type).ToLowerInvariant()} object.",
            Composition = "single dominant centred object, crisp silhouette, full object visible where practical, no text or logos",
            Mood = "clean, readable, evidence-like, story-ready",
            ObjectType = type,
            Colour = colour,
            Era = "Contemporary or late twentieth century",
            Palette = colour == "Unknown"
                ? ["clear blue", "warm light", "balanced neutral"]
                : [colour.ToLowerInvariant(), "clear blue", "warm light"],
            MetadataTags = ["asset", "demand-generated", "story-intelligence"],
            Metadata = new(StringComparer.OrdinalIgnoreCase)
            {
                ["libraryPurpose"] = "Story Intelligence demand-generated asset",
                ["privacy"] = "generic-no-manuscript-data",
                ["style"] = "editorial-stylised-realism",
                ["generationSource"] = "Demand generation",
                ["demandKey"] = demandKey,
                ["objectType"] = type,
                ["colour"] = colour
            }
        };

    private static string ReadableToken(string value)
        => Regex.Replace(value, "([a-z])([A-Z])", "$1 $2");

    private static string BalancedSkinToneFallback(string demandKey)
    {
        var hash = SHA256.HashData(Encoding.UTF8.GetBytes(demandKey));
        return (hash[0] % 3) switch
        {
            0 => "Light",
            1 => "Medium",
            _ => "Dark"
        };
    }

    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 Dictionary<string, CharacterObservation> BuildCharacterObservations(StoryIntelligenceExperiencePrototypeViewModel prototype)
    {
        var observations = new Dictionary<string, CharacterObservation>(StringComparer.OrdinalIgnoreCase);
        foreach (var scene in prototype.Scenes)
        {
            foreach (var character in scene.Characters)
            {
                if (string.IsNullOrWhiteSpace(character.Id) || string.IsNullOrWhiteSpace(character.Name))
                {
                    continue;
                }

                if (!observations.TryGetValue(character.Id, out var observation))
                {
                    observation = new CharacterObservation(character.Id, character.Name);
                    observations[character.Id] = observation;
                }

                observation.Significance = Math.Max(observation.Significance, character.Weight);
                observation.TextEvidence.Add(character.Name);
                observation.TextEvidence.Add(character.Role);
                observation.TextEvidence.Add(character.Relevance);
                observation.IdentityEvidence.Add(character.Name);
                observation.IdentityEvidence.Add(character.Role);
                observation.IdentityEvidence.Add(character.Relevance);
                foreach (var relationship in scene.Relationships.Where(item => string.Equals(item.SourceId, character.Id, StringComparison.OrdinalIgnoreCase)
                             || string.Equals(item.TargetId, character.Id, StringComparison.OrdinalIgnoreCase)))
                {
                    observation.TextEvidence.Add(relationship.Label);
                    observation.TextEvidence.Add(relationship.State);
                    observation.RelationshipEvidence.Add(relationship.Label);
                    observation.RelationshipEvidence.Add(relationship.State);
                }
            }
        }

        foreach (var observation in observations.Values)
        {
            observation.Evidence = CharacterEvidence.From(StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence(observation.Name, observation.IdentityEvidence, observation.RelationshipEvidence));
        }

        return observations;
    }

    private static CharacterEvidence InferEvidence(string characterName, IEnumerable<string> identityValues, IEnumerable<string> relationshipValues)
    {
        return CharacterEvidence.From(StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence(characterName, identityValues, relationshipValues));
    }

    private static bool ContainsAny(string text, params string[] needles)
        => needles.Any(needle => text.Contains(needle, StringComparison.OrdinalIgnoreCase));

    private static EvidenceSignal RelationshipPresentation(string text)
    {
        if (ContainsAny(text, "mother", " mum", "aunt", "sister", "daughter", "wife", "grandmother", " niece"))
        {
            return new("Feminine", 0.92m);
        }

        if (ContainsAny(text, "father", " dad", "uncle", "brother", "son", "husband", "grandfather", " nephew"))
        {
            return new("Masculine", 0.92m);
        }

        return EvidenceSignal.Unknown;
    }

    private static EvidenceSignal PronounPresentation(string text)
    {
        if (ContainsAny(text, " she ", " her ", " hers ", " herself "))
        {
            return new("Feminine", 0.86m);
        }

        if (ContainsAny(text, " he ", " him ", " his ", " himself "))
        {
            return new("Masculine", 0.86m);
        }

        return EvidenceSignal.Unknown;
    }

    private static EvidenceSignal NamePresentation(string text)
    {
        if (ContainsAny(text, " beth ", " maggie ", " rosie ", " grace ", " annie ", " helen ", " elen "))
        {
            return new("Feminine", 0.82m);
        }

        if (ContainsAny(text, " graham ", " george ", " john ", " david ", " simon ", " kevin ", " colin "))
        {
            return new("Masculine", 0.82m);
        }

        return EvidenceSignal.Unknown;
    }

    private static string FirstKnown(params string[] values)
        => values.FirstOrDefault(value => !IsUnknown(value) && !string.Equals(value, "Androgynous", StringComparison.OrdinalIgnoreCase)) ?? values.LastOrDefault() ?? "Unknown";

    private static MatchScore Score(CharacterCandidate candidate, CharacterObservation observation, bool alreadyAssigned)
    {
        var score = 0;
        var rejected = new List<string>(StoryIntelligenceIllustrationCompatibility.CharacterHardRejections(
            observation.Evidence.Profile,
            candidate.Metadata.Profile,
            isNamedCharacter: !IsUnresolvedName(observation.Name),
            alreadyAssignedToAnotherSignificantCharacter: alreadyAssigned && observation.IsSignificant));
        if (observation.Evidence.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Unknown
            && candidate.Metadata.AgeBand is StoryIntelligenceIllustrationCompatibility.AgeBands.Child
                or StoryIntelligenceIllustrationCompatibility.AgeBands.YoungTeen
                or StoryIntelligenceIllustrationCompatibility.AgeBands.OlderTeen)
        {
            rejected.Add("unknown age should use an adult-compatible illustration until explicit child or teen evidence appears");
        }

        AddScore(observation.Evidence.AgeBand, candidate.Metadata.AgeBand, 25, "age band");
        AddScore(observation.Evidence.Presentation, candidate.Metadata.Presentation, observation.Evidence.HasStrongPresentationEvidence ? 42 : 25, "presentation");
        AddScore(observation.Evidence.HairColour, candidate.Metadata.HairColour, 20, "hair colour");
        AddScore(observation.Evidence.SkinTone, candidate.Metadata.SkinTone, 20, "skin tone");
        AddScore(observation.Evidence.HairLength, candidate.Metadata.HairLength, 8, "hair length");
        AddScore(observation.Evidence.Glasses, candidate.Metadata.Glasses, 7, "glasses");
        AddScore(observation.Evidence.FacialHair, candidate.Metadata.FacialHair, 7, "facial hair");

        if (candidate.Item.Status == IllustrationLibraryStatuses.Approved)
        {
            score += 3;
        }

        return new(candidate, Math.Clamp(score, 0, 100), alreadyAssigned, rejected);

        void AddScore(string evidence, string value, int weight, string label)
        {
            if (IsUnknown(evidence))
            {
                score += weight / 2;
                return;
            }

            if (string.Equals(evidence, value, StringComparison.OrdinalIgnoreCase))
            {
                score += weight;
                return;
            }

            if (IsUnknown(value))
            {
                score += weight / 3;
                rejected.Add($"{label} unknown in illustration metadata");
                return;
            }

            rejected.Add($"{label} mismatch: wanted {evidence}, candidate {value}");
        }
    }

    private static decimal Confidence(int score)
        => Math.Round(Math.Clamp(score, 0, 100) / 100m, 2);

    private static string ReassignmentReason(CharacterObservation observation, MatchScore existing, MatchScore chosen)
        => existing.Rejections.Count > 0
            ? $"Reassigned {observation.Name} because {existing.Candidate.Item.StableCode} violated hard constraints: {string.Join("; ", existing.Rejections)}."
            : $"Reassigned {observation.Name} because new structured evidence made {existing.Candidate.Item.StableCode} materially weaker than {chosen.Candidate.Item.StableCode}.";

    private void ApplyAssignment(
        StoryIntelligenceExperiencePrototypeViewModel prototype,
        CharacterObservation observation,
        StoryIntelligenceCharacterIllustrationAssignment? existing,
        MatchScore chosen,
        StoryIntelligenceCharacterIllustrationAssignment saved,
        string reason,
        string? reasonReassigned,
        string evidenceJson,
        string candidateDiagnostics)
    {
        foreach (var character in prototype.Scenes.SelectMany(scene => scene.Characters).Where(character => string.Equals(character.Id, observation.CharacterKey, StringComparison.OrdinalIgnoreCase)))
        {
            var fallbackImageUrl = string.IsNullOrWhiteSpace(character.ImageResolution.FallbackImageUrl)
                ? character.ImagePath
                : character.ImageResolution.FallbackImageUrl;
            character.LibraryCode = chosen.Candidate.Item.StableCode;
            character.ImagePath = chosen.Candidate.PublicUrl!;
            character.ImageResolution = new StoryIntelligenceExperienceImageResolution
            {
                EntityId = character.Id,
                StableCode = chosen.Candidate.Item.StableCode,
                IllustrationLibraryItemId = chosen.Candidate.Item.IllustrationLibraryItemID,
                Status = chosen.Candidate.Item.Status,
                FinalImageUrl = chosen.Candidate.PublicUrl,
                FallbackImageUrl = fallbackImageUrl,
                FallbackUsed = false,
                AssignmentConfidence = saved.MatchConfidence,
                MatchingScore = saved.MatchingScore,
                RejectedCandidates = candidateDiagnostics,
                ReasonChosen = reason,
                ReasonReassigned = reasonReassigned,
                PreviousIllustration = existing?.StableCode,
                PreviousEvidence = existing?.EvidenceJson,
                CurrentEvidence = evidenceJson,
                DemandKey = DemandKey(observation.Evidence),
                EvidenceWarnings = string.Join(" ", observation.Evidence.Warnings),
                DemandStatus = "Satisfied",
                ResolvedPresentation = observation.Evidence.Presentation,
                ResolvedAgeBand = observation.Evidence.AgeBand,
                RelationshipDerivedConfidence = observation.Evidence.RelationshipConfidence,
                NameDerivedConfidence = observation.Evidence.NameConfidence,
                PronounDerivedConfidence = observation.Evidence.PronounConfidence,
                IllustrationReuseCount = 1,
                IllustrationAllocationStatus = "Unique allocation"
            };
        }
    }

    private static void ApplyDemandDiagnostics(StoryIntelligenceExperiencePrototypeViewModel prototype, CharacterObservation observation, IReadOnlyList<MatchScore> allScores, string demandStatus)
    {
        var diagnostics = JsonSerializer.Serialize(allScores.Take(6).Select(MatchDiagnostics.From), JsonOptions);
        foreach (var character in prototype.Scenes.SelectMany(scene => scene.Characters).Where(character => string.Equals(character.Id, observation.CharacterKey, StringComparison.OrdinalIgnoreCase)))
        {
            var fallbackImageUrl = string.IsNullOrWhiteSpace(character.ImageResolution.FallbackImageUrl)
                ? character.ImagePath
                : character.ImageResolution.FallbackImageUrl;
            character.LibraryCode = string.Empty;
            if (!string.IsNullOrWhiteSpace(fallbackImageUrl))
            {
                character.ImagePath = fallbackImageUrl;
            }

            character.ImageResolution = new StoryIntelligenceExperienceImageResolution
            {
                EntityId = character.Id,
                StableCode = string.Empty,
                IllustrationLibraryItemId = null,
                Status = "DemandRecorded",
                FinalImageUrl = null,
                FallbackImageUrl = fallbackImageUrl,
                FallbackUsed = true,
                MatchingScore = allScores.FirstOrDefault()?.Score,
                RejectedCandidates = diagnostics,
                ReasonChosen = "No suitable unused illustration matched structured character evidence.",
                CurrentEvidence = EvidenceTraceJson(observation, null),
                DemandKey = DemandKey(observation.Evidence),
                EvidenceWarnings = string.Join(" ", observation.Evidence.Warnings),
                DemandStatus = demandStatus,
                ResolvedPresentation = observation.Evidence.Presentation,
                ResolvedAgeBand = observation.Evidence.AgeBand,
                RelationshipDerivedConfidence = observation.Evidence.RelationshipConfidence,
                NameDerivedConfidence = observation.Evidence.NameConfidence,
                PronounDerivedConfidence = observation.Evidence.PronounConfidence,
                IllustrationAllocationStatus = "Awaiting generated unique illustration"
            };
        }
    }

    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 string EncodeUrlPath(string path)
        => string.Join(
            '/',
            path.Split('/', StringSplitOptions.None)
                .Select((segment, index) => index == 0 && segment.Length == 0 ? string.Empty : Uri.EscapeDataString(segment)));

    private static bool IsUnknown(string? value)
        => string.IsNullOrWhiteSpace(value) || string.Equals(value, "Unknown", StringComparison.OrdinalIgnoreCase);

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

    private static bool IsUnresolvedName(string? value)
    {
        var clean = (value ?? string.Empty).Trim().ToLowerInvariant();
        return string.IsNullOrWhiteSpace(clean)
               || clean is "unknown" or "unknown figure" or "unidentified person" or "unidentified woman" or "unidentified man" or "narrator"
               || clean.StartsWith("unknown ", StringComparison.Ordinal)
               || clean.StartsWith("unidentified ", StringComparison.Ordinal);
    }

    private static void ApplyGroupDiagnostics(StoryIntelligenceExperiencePrototypeViewModel prototype, CharacterObservation observation)
    {
        foreach (var character in prototype.Scenes.SelectMany(scene => scene.Characters).Where(character => string.Equals(character.Id, observation.CharacterKey, StringComparison.OrdinalIgnoreCase)))
        {
            character.LibraryCode = string.Empty;
            character.ImagePath = "/images/story-intelligence/prototype/character-group.svg";
            character.ImageResolution = new StoryIntelligenceExperienceImageResolution
            {
                EntityId = character.Id,
                StableCode = string.Empty,
                IllustrationLibraryItemId = null,
                Status = "GroupEntity",
                FinalImageUrl = null,
                FallbackImageUrl = character.ImagePath,
                FallbackUsed = true,
                ReasonChosen = "Collective group entity; reusable group fallback used without unique portrait assignment or demand.",
                CurrentEvidence = EvidenceTraceJson(observation, null),
                DemandStatus = "Not required for group entity",
                ResolvedPresentation = "Unknown",
                ResolvedAgeBand = "Unknown",
                IllustrationAllocationStatus = "Group entity"
            };
        }
    }

    private static string NormaliseUnknown(string? value, string fallback)
        => IsUnknown(value) ? fallback : value!;

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

    private sealed class CharacterObservation(string characterKey, string name)
    {
        public string CharacterKey { get; } = characterKey;
        public string Name { get; } = name;
        public int Significance { get; set; }
        public bool IsSignificant => Significance >= 50;
        public List<string> TextEvidence { get; } = [];
        public List<string> IdentityEvidence { get; } = [];
        public List<string> RelationshipEvidence { get; } = [];
        public CharacterEvidence Evidence { get; set; } = CharacterEvidence.Unknown;
    }

    private sealed record EvidenceSignal(string Value, decimal Confidence)
    {
        public static EvidenceSignal Unknown { get; } = new("Unknown", 0);
    }

    private sealed record CharacterEvidence(
        string AgeBand,
        string Presentation,
        string HairColour,
        string SkinTone,
        string HairLength,
        string Glasses,
        string FacialHair,
        decimal RelationshipConfidence,
        decimal NameConfidence,
        decimal PronounConfidence,
        IReadOnlyList<string> Warnings,
        CharacterEvidenceProfile Profile)
    {
        public bool HasStrongPresentationEvidence => RelationshipConfidence >= 0.8m || NameConfidence >= 0.75m || PronounConfidence >= 0.8m;
        public static CharacterEvidence Unknown { get; } = From(new CharacterEvidenceProfile("Unknown", "Unknown", "Unknown", "Unknown", "Unknown", "Unknown", "Unknown", 0, 0, 0, 0, 0, []));
        public static CharacterEvidence From(CharacterEvidenceProfile profile)
            => new(
                profile.AgeBand,
                profile.Presentation,
                profile.HairColour,
                profile.SkinTone,
                profile.HairLength,
                profile.Glasses,
                profile.FacialHair,
                profile.RelationshipConfidence,
                profile.NameConfidence,
                profile.PronounConfidence,
                profile.Warnings,
                profile);
    }

    private sealed record CharacterCandidate(IllustrationLibraryItem Item, CharacterIllustrationMetadata Metadata, string? PublicUrl)
    {
        public static CharacterCandidate? From(IllustrationLibraryItem item)
        {
            var metadata = CharacterIllustrationMetadata.From(item);
            return metadata is null ? null : new(item, metadata, null);
        }
    }

    private sealed record CharacterIllustrationMetadata(string AgeBand, string Presentation, string HairColour, string SkinTone, string HairLength, string Glasses, string FacialHair, IllustrationCharacterMetadata Profile)
    {
        public static CharacterIllustrationMetadata? From(IllustrationLibraryItem item)
        {
            var profile = StoryIntelligenceIllustrationCompatibility.CharacterMetadataFrom(item);

            return new(
                profile.AgeBand,
                profile.Presentation,
                profile.HairColour,
                profile.SkinTone,
                profile.HairLength,
                profile.Glasses,
                profile.FacialHair,
                profile);
        }
    }

    private sealed record MatchScore(CharacterCandidate Candidate, int Score, bool IsAlreadyAssignedToAnotherSignificantCharacter, IReadOnlyList<string> Rejections);

    private sealed record MatchDiagnostics(int IllustrationLibraryItemID, string StableCode, int Score, IReadOnlyList<string> Rejections)
    {
        public static MatchDiagnostics From(MatchScore score)
            => new(score.Candidate.Item.IllustrationLibraryItemID, score.Candidate.Item.StableCode, score.Score, score.Rejections);
    }
}
