using Microsoft.EntityFrameworkCore; using Nuuru.Server.Data; using Nuuru.Server.Models.Booru; namespace Nuuru.Server.Services { public interface ITagService { Task GetTagByNameAsync(string name); Task GetTagByFullNameAsync(string fullName); Task GetTagByIdAsync(Guid id); Task GetOrCreateTagAsync(string fullName); Task> GetPopularTagsAsync(int count = 20); Task> SearchTagsAsync(string query, int limit = 10); Task UpdatePostCountAsync(Guid tagId); Task> GetAllTagsAsync(); Task<(IEnumerable Tags, int TotalCount)> GetTagsChunkAsync(int offset, int limit); Task> GetTagsChangedSinceAsync(DateTime since); Task UpdatePostTagsAsync(Post post, IEnumerable tagNames); Task GetOrCreateTagCategoryAsync(string slug, string? name = null); string? ValidateTagsForCategory(IEnumerable tags, PostCategory category); } public class TagService : ITagService { private readonly ApplicationDbContext _context; private readonly ILogger _logger; public TagService(ApplicationDbContext context, ILogger logger) { _context = context; _logger = logger; } public async Task GetTagByNameAsync(string name) { return await _context.BooruTags .FirstOrDefaultAsync(t => t.Name == name.Trim().ToLowerInvariant()); } public async Task GetTagByFullNameAsync(string fullName) { fullName = fullName.ToLowerInvariant().Trim(); var parts = fullName.Split(':'); string? categorySlug = parts.Length > 1 ? parts[0] : null; string name = parts.Length > 1 ? parts[1] : fullName; if (categorySlug == null) { return await _context.BooruTags .Include(t => t.Category) .FirstOrDefaultAsync(t => t.Name == name && t.Category == null); } else { return await _context.BooruTags .Include(t => t.Category) .FirstOrDefaultAsync(t => t.Name == name && t.Category != null && t.Category.Slug == categorySlug); } } public async Task GetTagByIdAsync(Guid id) { return await _context.BooruTags.FindAsync(id); } public async Task GetOrCreateTagAsync(string fullName) { fullName = fullName.ToLowerInvariant().Trim(); // Validate tag length if (string.IsNullOrWhiteSpace(fullName) || fullName.Length > 150) { throw new ArgumentException("Tag name must be between 1 and 150 characters", nameof(fullName)); } var parts = fullName.Split(':'); string? categorySlug = parts.Length > 1 ? parts[0] : null; string name = parts.Length > 1 ? parts[1] : fullName; // Validate individual parts if (name.Length > 100) { throw new ArgumentException("Tag name (excluding category) must not exceed 100 characters", nameof(fullName)); } if (categorySlug != null && categorySlug.Length > 50) { throw new ArgumentException("Tag category must not exceed 50 characters", nameof(fullName)); } // Get or create the tag category (if specified) TagCategory? category = categorySlug != null ? await GetOrCreateTagCategoryAsync(categorySlug) : null; // Look up by name AND category (they can coexist with different categories) var existingTag = await _context.BooruTags .Include(t => t.Category) .FirstOrDefaultAsync(t => t.Name == name && (category == null ? t.Category == null : t.Category == category)); if (existingTag != null) { return existingTag; } var tag = new Tag { Name = name, Category = category, PostCount = 0 }; _context.BooruTags.Add(tag); await _context.SaveChangesAsync(); return tag; } public async Task> GetPopularTagsAsync(int count = 20) { return await _context.BooruTags .Include(t => t.Category) .Where(t => t.PostCount > 0) .OrderByDescending(t => t.PostCount) .Take(count) .ToListAsync(); } public async Task> GetAllTagsAsync() { return await _context.BooruTags .Include(t => t.Category) .Where(t => t.PostCount > 0) .OrderByDescending(t => t.PostCount) .ToListAsync(); } public async Task<(IEnumerable Tags, int TotalCount)> GetTagsChunkAsync(int offset, int limit) { var baseQuery = _context.BooruTags .Where(t => t.PostCount > 0); var totalCount = await baseQuery.CountAsync(); var tags = await baseQuery .OrderBy(t => t.Id) .Skip(offset) .Take(limit) .Include(t => t.Category) .ToListAsync(); return (tags, totalCount); } public async Task> GetTagsChangedSinceAsync(DateTime since) { return await _context.BooruTags .Include(t => t.Category) .Where(t => t.UpdatedAt > since || t.CreatedAt > since) .OrderByDescending(t => t.PostCount) .ToListAsync(); } public async Task> SearchTagsAsync(string query, int limit = 10) { query = query.ToLowerInvariant(); return await _context.BooruTags .Include(t => t.Category) .Where(t => t.Name.Contains(query) && t.PostCount > 0) .OrderByDescending(t => t.PostCount) .Take(limit) .ToListAsync(); } public async Task UpdatePostCountAsync(Guid tagId) { var tag = await _context.BooruTags.FindAsync(tagId); if (tag == null) return; // Use COUNT query instead of loading all PostTags (exclude trashed posts) var count = await _context.Set().CountAsync(pt => pt.TagId == tagId && !pt.Post.IsTrashed); tag.PostCount = count; tag.UpdatedAt = DateTime.UtcNow; // Delete orphaned tags (no posts) if (count == 0) { // Check if used as alias or implication bool isReferenced = await _context.BooruTagAliases.AnyAsync(a => a.AliasTagId == tagId || a.TargetTagId == tagId) || await _context.BooruTagImplications.AnyAsync(i => i.AntecedentTagId == tagId || i.ConsequentTagId == tagId); if (!isReferenced) { _context.BooruTags.Remove(tag); } } await _context.SaveChangesAsync(); } public async Task UpdatePostTagsAsync(Post post, IEnumerable tagNames) { // Load existing PostTags await _context.Entry(post) .Collection(p => p.PostTags) .LoadAsync(); // Store old tag IDs for count updates var oldTagIds = post.PostTags.Select(pt => pt.TagId).ToList(); // Remove non-locked tags var tagsToRemove = post.PostTags.Where(pt => !pt.IsLocked).ToList(); foreach (var postTag in tagsToRemove) { post.PostTags.Remove(postTag); } // Get or create all input tags first var inputTags = new List(); foreach (var tagName in tagNames) { var tag = await GetOrCreateTagAsync(tagName); if (tag != null) { inputTags.Add(tag); } } // Resolve aliases and expand implications var resolvedTags = await ResolveAndExpandTagsAsync(inputTags); // Add resolved tags foreach (var tag in resolvedTags) { if (!post.PostTags.Any(pt => pt.TagId == tag.Id)) { post.PostTags.Add(new PostTag { PostId = post.Id, TagId = tag.Id, AddedAt = DateTime.UtcNow, IsLocked = false }); } } // Save the changes to the post's tags post.TagCount = post.PostTags.Count; await _context.SaveChangesAsync(); // Update post counts for all affected tags var newTagIds = post.PostTags.Select(pt => pt.TagId).ToList(); var affectedTagIds = oldTagIds.Union(newTagIds).Distinct(); foreach (var tagId in affectedTagIds) { await UpdatePostCountAsync(tagId); } } private async Task> ResolveAndExpandTagsAsync(IEnumerable tags) { var tagList = tags.ToList(); // Step 1: Resolve aliases var resolved = new List(); foreach (var tag in tagList) { resolved.Add(await ResolveAliasAsync(tag)); } resolved = resolved.DistinctBy(t => t.Id).ToList(); // Step 2: Expand implications var allTags = new HashSet(resolved.Select(t => t.Id)); var toProcess = new Queue(allTags); var depth = 0; const int maxDepth = 10; while (toProcess.Count > 0 && depth < maxDepth) { var batch = new List(); while (toProcess.Count > 0) batch.Add(toProcess.Dequeue()); var implications = await _context.BooruTagImplications .Where(i => batch.Contains(i.AntecedentTagId) && i.IsActive) .Select(i => i.ConsequentTagId) .ToListAsync(); foreach (var impliedId in implications) { if (allTags.Add(impliedId)) toProcess.Enqueue(impliedId); } depth++; } // Fetch all tag entities return await _context.BooruTags .Include(t => t.Category) .Where(t => allTags.Contains(t.Id)) .ToListAsync(); } private async Task ResolveAliasAsync(Tag tag) { var visited = new HashSet(); var current = tag; var depth = 0; const int maxDepth = 10; while (depth < maxDepth) { 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 GetOrCreateTagCategoryAsync(string slug, string? name = null) { slug = slug.ToLowerInvariant().Trim(); var existingCategory = await _context.BooruTagCategories .FirstOrDefaultAsync(c => c.Slug == slug); if (existingCategory != null) { return existingCategory; } // Create a new category with sensible defaults var category = new TagCategory { Name = name ?? CapitalizeFirst(slug), Slug = slug, ColorHex = "#9e9e9e", SortOrder = 0, IsActive = true }; _context.BooruTagCategories.Add(category); await _context.SaveChangesAsync(); return category; } public string? ValidateTagsForCategory(IEnumerable tags, PostCategory category) { if (category == PostCategory.Artworks) { if (!tags.Any(t => t.StartsWith("media:", StringComparison.OrdinalIgnoreCase))) { return "Artworks gallery requires at least one media: tag (e.g., media:ongezellig, media:original_content)"; } if (tags.Any(t => t.StartsWith("variant:", StringComparison.OrdinalIgnoreCase) || t.StartsWith("nas:", StringComparison.OrdinalIgnoreCase))) { return "Artworks gallery cannot have variant: or nas: tags"; } } else if (category == PostCategory.Gallery) { if (!tags.Any(t => t.StartsWith("variant:", StringComparison.OrdinalIgnoreCase) || t.StartsWith("nas:", StringComparison.OrdinalIgnoreCase))) { return "Gallery requires at least one variant: or nas: tag (e.g., variant:soyak, nas:pepe)"; } } return null; } private string CapitalizeFirst(string input) { if (string.IsNullOrEmpty(input)) return input; return char.ToUpper(input[0]) + input.Substring(1); } } }