using Microsoft.EntityFrameworkCore; using Nuuru.Server.Data; using Nuuru.Server.DTOs; using Nuuru.Server.DTOs.Admin; using Nuuru.Server.Extensions; using Nuuru.Server.Models.Booru; namespace Nuuru.Server.Services { public interface ITagRelationService { // Alias operations Task ResolveAliasAsync(Tag tag); Task ResolveAliasAsync(Guid tagId); Task> ResolveAliasesAsync(IEnumerable tags); Task> GetAllAliasesAsync(int page = 1, int pageSize = 50); Task> SearchAliasesAsync(string query, int limit = 20); Task GetAliasAsync(Guid aliasId); Task CreateAliasAsync(CreateTagAliasRequest request, Guid? createdByUserId); Task DeleteAliasAsync(Guid aliasId); // Implication operations Task> GetImpliedTagsAsync(Tag tag); Task> GetImpliedTagsAsync(Guid tagId); Task> GetAllImpliedTagsAsync(IEnumerable tags); Task> GetAllImplicationsAsync(int page = 1, int pageSize = 50); Task> SearchImplicationsAsync(string query, int limit = 20); Task GetImplicationAsync(Guid implicationId); Task CreateImplicationAsync(CreateTagImplicationRequest request, Guid? createdByUserId); Task DeleteImplicationAsync(Guid implicationId); // Cycle detection Task WouldCreateAliasCycleAsync(Guid aliasTagId, Guid targetTagId); Task WouldCreateImplicationCycleAsync(Guid antecedentTagId, Guid consequentTagId); // Combined resolution for post tagging Task> ResolveAndExpandTagsAsync(IEnumerable tags); } public class TagRelationService : ITagRelationService { private readonly ApplicationDbContext _context; private readonly ITagService _tagService; private readonly ILogger _logger; private const int MaxChainDepth = 10; public TagRelationService( ApplicationDbContext context, ITagService tagService, ILogger logger) { _context = context; _tagService = tagService; _logger = logger; } #region Alias Operations public async Task ResolveAliasAsync(Tag tag) { var visited = new HashSet(); var current = tag; var depth = 0; while (depth < MaxChainDepth) { if (!visited.Add(current.Id)) { _logger.LogError("Alias cycle detected for tag {TagId}", tag.Id); break; } var alias = await _context.BooruTagAliases .Include(a => a.TargetTag) .ThenInclude(t => t.Category) .FirstOrDefaultAsync(a => a.AliasTagId == current.Id && a.IsActive); if (alias == null) break; current = alias.TargetTag; depth++; } return current; } public async Task ResolveAliasAsync(Guid tagId) { var tag = await _context.BooruTags .Include(t => t.Category) .FirstOrDefaultAsync(t => t.Id == tagId); if (tag == null) throw new ArgumentException($"Tag with ID {tagId} not found"); return await ResolveAliasAsync(tag); } public async Task> ResolveAliasesAsync(IEnumerable tags) { var resolved = new List(); foreach (var tag in tags) { resolved.Add(await ResolveAliasAsync(tag)); } return resolved.DistinctBy(t => t.Id); } public async Task> GetAllAliasesAsync(int page = 1, int pageSize = 50) { var query = _context.BooruTagAliases .Include(a => a.AliasTag).ThenInclude(t => t.Category) .Include(a => a.TargetTag).ThenInclude(t => t.Category) .Include(a => a.CreatedBy) .OrderByDescending(a => a.CreatedAt); var totalCount = await query.CountAsync(); var items = await query .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(); return new PagedResult { Items = items.Select(ToDto), TotalCount = totalCount, Page = page, PageSize = pageSize }; } public async Task> SearchAliasesAsync(string query, int limit = 20) { query = query.ToLowerInvariant(); var aliases = await _context.BooruTagAliases .Include(a => a.AliasTag).ThenInclude(t => t.Category) .Include(a => a.TargetTag).ThenInclude(t => t.Category) .Include(a => a.CreatedBy) .Where(a => a.AliasTag.Name.Contains(query) || a.TargetTag.Name.Contains(query)) .OrderByDescending(a => a.CreatedAt) .Take(limit) .ToListAsync(); return aliases.Select(ToDto); } public async Task GetAliasAsync(Guid aliasId) { var alias = await _context.BooruTagAliases .Include(a => a.AliasTag).ThenInclude(t => t.Category) .Include(a => a.TargetTag).ThenInclude(t => t.Category) .Include(a => a.CreatedBy) .FirstOrDefaultAsync(a => a.Id == aliasId); return alias != null ? ToDto(alias) : null; } public async Task CreateAliasAsync(CreateTagAliasRequest request, Guid? createdByUserId) { // Get or create the alias tag var aliasTag = await _tagService.GetOrCreateTagAsync(request.AliasTagName); // Get or create the target tag var targetTag = await _tagService.GetOrCreateTagAsync(request.TargetTagName); // Validate: can't alias to itself if (aliasTag.Id == targetTag.Id) throw new InvalidOperationException("A tag cannot alias to itself"); // Validate: check if alias already exists var existingAlias = await _context.BooruTagAliases .FirstOrDefaultAsync(a => a.AliasTagId == aliasTag.Id); if (existingAlias != null) throw new InvalidOperationException($"Tag '{request.AliasTagName}' is already aliased to another tag"); // Resolve target tag's alias chain to get the canonical tag var resolvedTarget = await ResolveAliasAsync(targetTag); // Check for cycles if (await WouldCreateAliasCycleAsync(aliasTag.Id, resolvedTarget.Id)) throw new InvalidOperationException("Creating this alias would create a cycle"); var alias = new TagAlias { AliasTagId = aliasTag.Id, TargetTagId = resolvedTarget.Id, CreatedByUserId = createdByUserId, IsActive = true }; _context.BooruTagAliases.Add(alias); await _context.SaveChangesAsync(); // Reload with includes await _context.Entry(alias).Reference(a => a.AliasTag).LoadAsync(); await _context.Entry(alias.AliasTag).Reference(t => t.Category).LoadAsync(); await _context.Entry(alias).Reference(a => a.TargetTag).LoadAsync(); await _context.Entry(alias.TargetTag).Reference(t => t.Category).LoadAsync(); if (createdByUserId.HasValue) await _context.Entry(alias).Reference(a => a.CreatedBy).LoadAsync(); return ToDto(alias); } public async Task DeleteAliasAsync(Guid aliasId) { var alias = await _context.BooruTagAliases.FindAsync(aliasId); if (alias == null) return false; _context.BooruTagAliases.Remove(alias); await _context.SaveChangesAsync(); return true; } public async Task WouldCreateAliasCycleAsync(Guid aliasTagId, Guid targetTagId) { // Check if targetTagId eventually aliases back to aliasTagId var visited = new HashSet(); var current = targetTagId; var depth = 0; while (depth < MaxChainDepth) { if (current == aliasTagId) return true; if (!visited.Add(current)) break; var nextAlias = await _context.BooruTagAliases .Where(a => a.AliasTagId == current && a.IsActive) .Select(a => a.TargetTagId) .FirstOrDefaultAsync(); if (nextAlias == default) break; current = nextAlias; depth++; } return false; } #endregion #region Implication Operations public async Task> GetImpliedTagsAsync(Tag tag) { return await GetImpliedTagsAsync(tag.Id); } public async Task> GetImpliedTagsAsync(Guid tagId) { var impliedIds = new HashSet(); var toProcess = new Queue(); toProcess.Enqueue(tagId); var depth = 0; while (toProcess.Count > 0 && depth < MaxChainDepth) { var currentId = toProcess.Dequeue(); var implications = await _context.BooruTagImplications .Where(i => i.AntecedentTagId == currentId && i.IsActive) .Select(i => i.ConsequentTagId) .ToListAsync(); foreach (var impliedId in implications) { if (impliedIds.Add(impliedId)) toProcess.Enqueue(impliedId); } depth++; } // Remove the original tag if it's in the set impliedIds.Remove(tagId); return await _context.BooruTags .Include(t => t.Category) .Where(t => impliedIds.Contains(t.Id)) .ToListAsync(); } public async Task> GetAllImpliedTagsAsync(IEnumerable tags) { var allImplied = new HashSet(); var inputIds = tags.Select(t => t.Id).ToHashSet(); foreach (var tag in tags) { var implied = await GetImpliedTagsAsync(tag); foreach (var impliedTag in implied) { if (!inputIds.Contains(impliedTag.Id)) allImplied.Add(impliedTag.Id); } } return await _context.BooruTags .Include(t => t.Category) .Where(t => allImplied.Contains(t.Id)) .ToListAsync(); } public async Task> GetAllImplicationsAsync(int page = 1, int pageSize = 50) { var query = _context.BooruTagImplications .Include(i => i.AntecedentTag).ThenInclude(t => t.Category) .Include(i => i.ConsequentTag).ThenInclude(t => t.Category) .Include(i => i.CreatedBy) .OrderByDescending(i => i.CreatedAt); var totalCount = await query.CountAsync(); var items = await query .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(); return new PagedResult { Items = items.Select(ToDto), TotalCount = totalCount, Page = page, PageSize = pageSize }; } public async Task> SearchImplicationsAsync(string query, int limit = 20) { query = query.ToLowerInvariant(); var implications = await _context.BooruTagImplications .Include(i => i.AntecedentTag).ThenInclude(t => t.Category) .Include(i => i.ConsequentTag).ThenInclude(t => t.Category) .Include(i => i.CreatedBy) .Where(i => i.AntecedentTag.Name.Contains(query) || i.ConsequentTag.Name.Contains(query)) .OrderByDescending(i => i.CreatedAt) .Take(limit) .ToListAsync(); return implications.Select(ToDto); } public async Task GetImplicationAsync(Guid implicationId) { var implication = await _context.BooruTagImplications .Include(i => i.AntecedentTag).ThenInclude(t => t.Category) .Include(i => i.ConsequentTag).ThenInclude(t => t.Category) .Include(i => i.CreatedBy) .FirstOrDefaultAsync(i => i.Id == implicationId); return implication != null ? ToDto(implication) : null; } public async Task CreateImplicationAsync(CreateTagImplicationRequest request, Guid? createdByUserId) { // Get or create the antecedent tag var antecedentTag = await _tagService.GetOrCreateTagAsync(request.AntecedentTagName); // Get or create the consequent tag var consequentTag = await _tagService.GetOrCreateTagAsync(request.ConsequentTagName); // Validate: can't imply itself if (antecedentTag.Id == consequentTag.Id) throw new InvalidOperationException("A tag cannot imply itself"); // Validate: check if implication already exists var existingImplication = await _context.BooruTagImplications .FirstOrDefaultAsync(i => i.AntecedentTagId == antecedentTag.Id && i.ConsequentTagId == consequentTag.Id); if (existingImplication != null) throw new InvalidOperationException($"This implication already exists"); // Check for cycles if (await WouldCreateImplicationCycleAsync(antecedentTag.Id, consequentTag.Id)) throw new InvalidOperationException("Creating this implication would create a cycle"); var implication = new TagImplication { AntecedentTagId = antecedentTag.Id, ConsequentTagId = consequentTag.Id, CreatedByUserId = createdByUserId, IsActive = true }; _context.BooruTagImplications.Add(implication); await _context.SaveChangesAsync(); // Reload with includes await _context.Entry(implication).Reference(i => i.AntecedentTag).LoadAsync(); await _context.Entry(implication.AntecedentTag).Reference(t => t.Category).LoadAsync(); await _context.Entry(implication).Reference(i => i.ConsequentTag).LoadAsync(); await _context.Entry(implication.ConsequentTag).Reference(t => t.Category).LoadAsync(); if (createdByUserId.HasValue) await _context.Entry(implication).Reference(i => i.CreatedBy).LoadAsync(); return ToDto(implication); } public async Task DeleteImplicationAsync(Guid implicationId) { var implication = await _context.BooruTagImplications.FindAsync(implicationId); if (implication == null) return false; _context.BooruTagImplications.Remove(implication); await _context.SaveChangesAsync(); return true; } public async Task WouldCreateImplicationCycleAsync(Guid antecedentTagId, Guid consequentTagId) { // Check if consequentTagId eventually implies antecedentTagId var visited = new HashSet(); var toProcess = new Queue(); toProcess.Enqueue(consequentTagId); var depth = 0; while (toProcess.Count > 0 && depth < MaxChainDepth) { var currentId = toProcess.Dequeue(); if (currentId == antecedentTagId) return true; if (!visited.Add(currentId)) continue; var implications = await _context.BooruTagImplications .Where(i => i.AntecedentTagId == currentId && i.IsActive) .Select(i => i.ConsequentTagId) .ToListAsync(); foreach (var impliedId in implications) { if (!visited.Contains(impliedId)) toProcess.Enqueue(impliedId); } depth++; } return false; } #endregion #region Combined Resolution public async Task> ResolveAndExpandTagsAsync(IEnumerable tags) { // First, resolve all aliases var resolved = await ResolveAliasesAsync(tags); var resolvedList = resolved.ToList(); // Then, expand all implications var implied = await GetAllImpliedTagsAsync(resolvedList); // Combine and deduplicate var result = resolvedList.Concat(implied).DistinctBy(t => t.Id).ToList(); return result; } #endregion #region Mapping private static TagAliasDto ToDto(TagAlias alias) { return new TagAliasDto { Id = alias.Id, AliasTag = alias.AliasTag.ToDto(), TargetTag = alias.TargetTag.ToDto(), CreatedAt = alias.CreatedAt, CreatedByUsername = alias.CreatedBy?.UserName, IsActive = alias.IsActive }; } private static TagImplicationDto ToDto(TagImplication implication) { return new TagImplicationDto { Id = implication.Id, AntecedentTag = implication.AntecedentTag.ToDto(), ConsequentTag = implication.ConsequentTag.ToDto(), CreatedAt = implication.CreatedAt, CreatedByUsername = implication.CreatedBy?.UserName, IsActive = implication.IsActive }; } #endregion } }