using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Text.RegularExpressions;
using Manager.DTOs;
using ManagerService.Data;
using ManagerService.Data.SubSection;
using ManagerService.DTOs;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
namespace ManagerService.Services
{
///
/// Index inverse des usages : quelle ressource est employée par quel contenu.
/// Parcourt dans l'autre sens — la même
/// méthode qui fait marcher l'export hors ligne, donc le même périmètre de champs.
///
///
/// Quatre points de vigilance, tous traités ici :
/// 1. Langue nulle = toutes les langues (voir SectionText.ResourceIds). Passer une langue
/// ferait passer pour orpheline une image posée uniquement en NL.
/// 2. Configuration.ImageId et LoaderImageId vivent hors des sections : ajoutés à part,
/// comme le fait ConfigurationController.Export.
/// 3. GuidedStep.ImageUrl est une URL absolue, pas un id : une image posée là est comptée
/// orpheline. Limite connue, migrée en V2, signalée dans l'UI.
/// 4. Un GuidedPath peut pendre d'un SectionEvent, dont GetReferencedResourceIds ne
/// descend pas dans les parcours. Les parcours sont donc parcourus séparément, et les
/// GuidedPaths ne sont volontairement pas chargés sur SectionParcours (pas de doublon).
///
public class ResourceUsageService
{
private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(30);
private readonly MyInfoMateDbContext _db;
private readonly IMemoryCache _cache;
public ResourceUsageService(MyInfoMateDbContext db, IMemoryCache cache)
{
_db = db;
_cache = cache;
}
/// Usages de toutes les ressources d'une instance, indexés par id de ressource.
public Dictionary> GetUsageMap(string instanceId)
{
return _cache.GetOrCreate($"resource-usages:{instanceId}", entry =>
{
entry.AbsoluteExpirationRelativeToNow = CacheDuration;
return Build(instanceId);
});
}
///
/// Ce que le rail de facettes consomme : un résumé par ressource — zéro compris, sans
/// quoi « Jamais utilisées » n'aurait rien à lire — et le libellé des configurations.
///
public ResourceUsageMapDTO GetUsageSummary(string instanceId)
{
var usages = GetUsageMap(instanceId);
var summary = new ResourceUsageMapDTO();
foreach (var id in _db.Resources.AsNoTracking()
.Where(r => r.InstanceId == instanceId)
.Select(r => r.Id)
.ToList())
{
usages.TryGetValue(id, out var list);
summary.usages[id] = new ResourceUsageSummaryDTO
{
count = list?.Count ?? 0,
configurationIds = (list ?? new List())
.Where(u => u.configurationId != null)
.Select(u => u.configurationId)
.Distinct()
.ToList()
};
}
foreach (var configuration in _db.Configurations.AsNoTracking()
.Where(c => c.InstanceId == instanceId)
.ToList())
summary.configurations[configuration.Id] = configuration.Label;
return summary;
}
/// Usages d'une ressource. Liste vide si la ressource n'existe pas.
public List GetUsages(string resourceId)
{
var instanceId = _db.Resources.AsNoTracking()
.Where(r => r.Id == resourceId)
.Select(r => r.InstanceId)
.FirstOrDefault();
if (instanceId == null)
return new List();
return GetUsageMap(instanceId).TryGetValue(resourceId, out var usages)
? usages
: new List();
}
public void Invalidate(string instanceId) => _cache.Remove($"resource-usages:{instanceId}");
private Dictionary> Build(string instanceId)
{
var map = new Dictionary>();
var configurations = _db.Configurations.AsNoTracking()
.Where(c => c.InstanceId == instanceId)
.ToList();
var configurationLabels = configurations.ToDictionary(c => c.Id, c => c.Label);
foreach (var configuration in configurations)
{
Add(map, configuration.ImageId, new ResourceUsageDTO
{
kind = "Configuration",
id = configuration.Id,
label = configuration.Label,
configurationId = configuration.Id,
configurationLabel = configuration.Label,
field = "image",
path = $"{configuration.Label} › image d'accueil"
});
Add(map, configuration.LoaderImageId, new ResourceUsageDTO
{
kind = "Configuration",
id = configuration.Id,
label = configuration.Label,
configurationId = configuration.Id,
configurationLabel = configuration.Label,
field = "loaderImage",
path = $"{configuration.Label} › écran de chargement"
});
}
var sections = LoadSections(instanceId);
foreach (var section in sections)
{
var configurationLabel = Label(configurationLabels, section.ConfigurationId);
foreach (var resourceId in section.GetReferencedResourceIds().Distinct())
{
var field = resourceId == section.ImageId ? "image" : "contenu";
Add(map, resourceId, new ResourceUsageDTO
{
kind = "Section",
id = section.Id,
label = section.Label,
configurationId = section.ConfigurationId,
configurationLabel = configurationLabel,
sectionId = section.Id,
field = field,
path = $"{configurationLabel} › {section.Label} › {field}"
});
}
}
var sectionsById = sections.ToDictionary(s => s.Id, s => s);
var guidedPaths = _db.GuidedPaths
.Where(p => p.InstanceId == instanceId)
.Include(p => p.Steps).ThenInclude(s => s.QuizQuestions)
.ToList();
foreach (var guidedPath in guidedPaths)
{
var parentId = guidedPath.SectionParcoursId ?? guidedPath.SectionEventId;
var parent = parentId != null && sectionsById.TryGetValue(parentId, out var s) ? s : null;
var configurationId = parent?.ConfigurationId;
var configurationLabel = Label(configurationLabels, configurationId);
var pathLabel = FirstTranslation(guidedPath.Title) ?? "Parcours";
var prefix = parent != null
? $"{configurationLabel} › {parent.Label} › {pathLabel}"
: $"{configurationLabel} › {pathLabel}";
var steps = guidedPath.Steps ?? new List();
var stepResourceIds = steps.SelectMany(step => step.GetReferencedResourceIds()).ToHashSet();
// Les ids propres au parcours : ceux de l'agrégat moins ceux de ses étapes,
// pour ne pas attribuer au parcours ce qui appartient à une étape.
foreach (var resourceId in guidedPath.GetReferencedResourceIds().Distinct()
.Where(id => !stepResourceIds.Contains(id)))
{
Add(map, resourceId, new ResourceUsageDTO
{
kind = "GuidedPath",
id = guidedPath.Id,
label = pathLabel,
configurationId = configurationId,
configurationLabel = configurationLabel,
sectionId = parent?.Id,
field = resourceId == guidedPath.ImageResourceId ? "image" : "contenu",
path = prefix
});
}
foreach (var step in steps)
{
var stepLabel = FirstTranslation(step.Title) ?? $"Étape {step.Order + 1}";
foreach (var resourceId in step.GetReferencedResourceIds().Distinct())
{
Add(map, resourceId, new ResourceUsageDTO
{
kind = "GuidedStep",
id = step.Id,
label = stepLabel,
configurationId = configurationId,
configurationLabel = configurationLabel,
sectionId = parent?.Id,
field = "contenu",
path = $"{prefix} › {stepLabel}"
});
}
}
}
return map;
}
///
/// Pas d'AsNoTracking : les colonnes jsonb passent par un convertisseur, et le
/// provider InMemory des tests rend des collections vides sur une requête détachée.
/// Le service ne modifie rien, le suivi ne coûte donc que de la mémoire.
///
/// Les sous-types dont le contenu vit dans des tables liées ont besoin d'un Include :
/// sans lui, la collection est vide et la section paraît ne référencer que sa vignette.
/// SectionParcours en est volontairement exclu — ses parcours sont traités à part.
///
private List LoadSections(string instanceId)
{
var sections = new List();
sections.AddRange(_db.Sections.OfType()
.Where(s => s.InstanceId == instanceId)
.Include(s => s.QuizQuestions));
sections.AddRange(_db.Sections.OfType()
.Where(s => s.InstanceId == instanceId)
.Include(s => s.EventAgendas));
sections.AddRange(_db.Sections.OfType()
.Where(s => s.InstanceId == instanceId)
.Include(s => s.Programme).ThenInclude(b => b.MapAnnotations)
.Include(s => s.GlobalMapAnnotations));
sections.AddRange(_db.Sections.OfType()
.Where(s => s.InstanceId == instanceId)
.Include(s => s.MapPoints));
var loaded = new[] { SectionType.Quiz, SectionType.Agenda, SectionType.Event, SectionType.Map };
sections.AddRange(_db.Sections
.Where(s => s.InstanceId == instanceId && !loaded.Contains(s.Type)));
return sections;
}
private static string Label(Dictionary labels, string configurationId) =>
configurationId != null && labels.TryGetValue(configurationId, out var label) ? label : "Sans configuration";
///
/// Les traductions sont stockées en HTML (l'éditeur Quill de manager-app les écrit
/// ainsi) : un titre de parcours vaut « <p>Chasse au trésor</p> ». Le chemin
/// affiché dans « Utilisée dans » est un fil d'Ariane sur une ligne, pas du contenu
/// riche — on en retire donc les balises plutôt que de les rendre.
///
private static string FirstTranslation(IEnumerable translations)
{
var value = translations?.FirstOrDefault(t => !string.IsNullOrWhiteSpace(StripHtml(t.value)))?.value;
return value == null ? null : StripHtml(value);
}
private static string StripHtml(string text) =>
text == null
? null
: Regex.Replace(WebUtility.HtmlDecode(Regex.Replace(text, "<[^>]+>", " ")), @"\s+", " ").Trim();
private static void Add(Dictionary> map, string resourceId, ResourceUsageDTO usage)
{
if (string.IsNullOrWhiteSpace(resourceId))
return;
if (!map.TryGetValue(resourceId, out var usages))
map[resourceId] = usages = new List();
usages.Add(usage);
}
}
}