using System.Diagnostics; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Nuuru.Server.Auth; using Nuuru.Server.Data; using Nuuru.Server.DTOs; using Nuuru.Server.DTOs.Booru; using Nuuru.Server.Extensions; namespace Nuuru.Server.Services.Search; /// /// Implementation of the search service with full booru-style query support. /// public class BooruSearchService : IBooruSearchService { private static readonly TimeSpan ParseCacheDuration = TimeSpan.FromMinutes(10); private static readonly TimeSpan CountCacheDuration = TimeSpan.FromMinutes(2); private readonly ApplicationDbContext _context; private readonly ICurrentUserContext _userContext; private readonly IUserBadgeService _userBadgeService; private readonly IUserSettingsService _userSettingsService; private readonly ISiteSettingsService _siteSettings; private readonly IMemoryCache _cache; private readonly ILogger _logger; public BooruSearchService( ApplicationDbContext context, ICurrentUserContext userContext, IUserBadgeService userBadgeService, IUserSettingsService userSettingsService, ISiteSettingsService siteSettings, IMemoryCache cache, ILogger logger) { _context = context; _userContext = userContext; _userBadgeService = userBadgeService; _userSettingsService = userSettingsService; _siteSettings = siteSettings; _cache = cache; _logger = logger; } public async Task SearchPostsAsync(string? query, int page = 1, int pageSize = 20) { var sw = Stopwatch.StartNew(); var warnings = new List(); try { // Prepend default search query from user settings string? defaultQuery = null; if (_userContext.UserId.HasValue) { defaultQuery = await _userSettingsService.GetDefaultSearchQueryAsync(_userContext.UserId.Value); } else { // For logged out users, filter explicit content by default defaultQuery = SearchDefaults.DefaultQuery; } var effectiveQuery = string.IsNullOrWhiteSpace(defaultQuery) ? query : string.IsNullOrWhiteSpace(query) ? defaultQuery : $"{defaultQuery} {query}"; // Handle empty/null query - return all posts if (string.IsNullOrWhiteSpace(effectiveQuery)) { return await GetDefaultResultsAsync(page, pageSize, sw); } // Parse with caching var parseResult = GetOrParseQuery(effectiveQuery); warnings.AddRange(parseResult.Warnings); if (parseResult.Errors.Count > 0) { warnings.AddRange(parseResult.Errors); return CreateEmptyResult(effectiveQuery, warnings, sw); } // Pre-resolve all tag IDs async in a single batch — subsequent sync resolvers // during query building will be instant cache hits with no blocking DB I/O var builder = new SearchQueryBuilder(_context, _userContext, _cache); await builder.PreResolveTagIdsAsync(parseResult); var dbQuery = builder.BuildQuery(parseResult); // Cache count per effective query + user to avoid duplicate COUNT scans // across concurrent requests. UserId determines permissions, so no need to key on both. var countCacheKey = $"search_count:{_userContext.UserId}:{effectiveQuery}"; if (!_cache.TryGetValue(countCacheKey, out int totalCount)) { var countQuery = builder.BuildCountQuery(parseResult); totalCount = await countQuery.CountAsync(); _cache.Set(countCacheKey, totalCount, CountCacheDuration); } var posts = await dbQuery .Skip((page - 1) * pageSize) .Take(pageSize) .AsSplitQuery() .ToListAsync(); var bointsEnabled = await _siteSettings.GetBoolAsync("boints.enabled", false); // On page 1, prepend boosted posts that match the query filters if (page == 1 && bointsEnabled) { var now = DateTime.UtcNow; var boostedQuery = builder.BuildQuery(parseResult) .Where(p => p.BoostedUntil != null && p.BoostedUntil > now); var boostedPosts = await boostedQuery .OrderByDescending(p => p.BoostedUntil) .Take(5) .AsSplitQuery() .ToListAsync(); if (boostedPosts.Count > 0) { var boostedIds = boostedPosts.Select(p => p.Id).ToHashSet(); posts = boostedPosts .Concat(posts.Where(p => !boostedIds.Contains(p.Id))) .ToList(); } } // Batch-fetch display info for all uploaders and approvers var userIds = posts .SelectMany(p => new[] { p.Uploader?.Id, p.ApprovedBy?.Id }) .Where(id => id.HasValue) .Select(id => id!.Value) .Distinct(); var displayInfoMap = await _userBadgeService.GetUsersDisplayInfoAsync(userIds); sw.Stop(); return new SearchResult { Posts = new PagedResult { Items = posts.ToDto(displayInfoMap), TotalCount = totalCount, Page = page, PageSize = pageSize }, Metadata = new SearchMetadata { ParsedQuery = query, Warnings = warnings, QueryTimeMs = sw.Elapsed.TotalMilliseconds } }; } catch (Exception ex) { _logger.LogError(ex, "Error executing search query: {Query}", query); sw.Stop(); return new SearchResult { Posts = new PagedResult { Items = [], TotalCount = 0, Page = page, PageSize = pageSize }, Metadata = new SearchMetadata { ParsedQuery = query ?? string.Empty, Warnings = ["An error occurred while executing the search"], QueryTimeMs = sw.Elapsed.TotalMilliseconds } }; } } public async Task> BuildQueryAsync(string? query) { var effectiveQuery = await GetEffectiveQueryAsync(query); if (string.IsNullOrWhiteSpace(effectiveQuery)) { // Default query: exclude trashed and handle approval status var canSeeUnapproved = _userContext.HasPermission(Permissions.Moderation.ApprovePost); var currentUserId = _userContext.UserId; var q = _context.BooruPosts .Where(p => !p.IsTrashed) .Include(p => p.Uploader) .Include(p => p.PostTags) .ThenInclude(pt => pt.Tag) .ThenInclude(t => t.Category) .AsQueryable(); if (!canSeeUnapproved) { if (currentUserId.HasValue) { q = q.Where(p => p.IsApproved || p.UploaderId == currentUserId.Value); } else { q = q.Where(p => p.IsApproved); } } return q.OrderByDescending(p => p.UploadedAt); } var parseResult = GetOrParseQuery(effectiveQuery); if (parseResult.Errors.Count > 0) { // Fallback to empty query on error return _context.BooruPosts.Where(p => false); } var builder = new SearchQueryBuilder(_context, _userContext, _cache); await builder.PreResolveTagIdsAsync(parseResult); return builder.BuildQuery(parseResult); } private async Task GetEffectiveQueryAsync(string? query) { // Prepend default search query from user settings string? defaultQuery = null; if (_userContext.UserId.HasValue) { defaultQuery = await _userSettingsService.GetDefaultSearchQueryAsync(_userContext.UserId.Value); } else { // For logged out users, filter explicit content by default defaultQuery = SearchDefaults.DefaultQuery; } return string.IsNullOrWhiteSpace(defaultQuery) ? query ?? "" : string.IsNullOrWhiteSpace(query) ? defaultQuery : $"{defaultQuery} {query}"; } public SearchValidationResult ValidateQuery(string query) { var errors = new List(); var warnings = new List(); if (string.IsNullOrWhiteSpace(query)) { return new SearchValidationResult { IsValid = true }; } try { var tokenizer = new SearchTokenizer(query); var tokens = tokenizer.Tokenize(); // Check for unbalanced braces var braceDepth = 0; foreach (var token in tokens) { if (token is Tokens.OrGroupStartToken) braceDepth++; if (token is Tokens.OrGroupEndToken) braceDepth--; if (braceDepth < 0) { errors.Add("Unmatched closing brace '}'"); break; } } if (braceDepth > 0) { errors.Add("Unclosed brace '{'"); } // Parse and collect warnings var parser = new SearchParser(tokens); var parseResult = parser.Parse(); errors.AddRange(parseResult.Errors); warnings.AddRange(parseResult.Warnings); } catch (Exception ex) { errors.Add($"Parse error: {ex.Message}"); } return new SearchValidationResult { IsValid = errors.Count == 0, Errors = errors, Warnings = warnings }; } private async Task GetDefaultResultsAsync(int page, int pageSize, Stopwatch sw) { // Build filter predicate based on approval status var canSeeUnapproved = _userContext.HasPermission(Permissions.Moderation.ApprovePost); var currentUserId = _userContext.UserId; // Cache the default count — no tag filters, just approval/trash checks var countCacheKey = $"search_count_default:{currentUserId}"; if (!_cache.TryGetValue(countCacheKey, out int totalCount)) { var countQuery = _context.BooruPosts.Where(p => !p.IsTrashed); if (!canSeeUnapproved) { if (currentUserId.HasValue) { countQuery = countQuery.Where(p => p.IsApproved || p.UploaderId == currentUserId.Value); } else { countQuery = countQuery.Where(p => p.IsApproved); } } totalCount = await countQuery.CountAsync(); _cache.Set(countCacheKey, totalCount, CountCacheDuration); } // Data query with includes - exclude trashed var dataQuery = _context.BooruPosts .Where(p => !p.IsTrashed) .Include(p => p.Uploader) .Include(p => p.PostTags) .ThenInclude(pt => pt.Tag) .ThenInclude(t => t.Category) .AsQueryable(); if (!canSeeUnapproved) { if (currentUserId.HasValue) { dataQuery = dataQuery.Where(p => p.IsApproved || p.UploaderId == currentUserId.Value); } else { dataQuery = dataQuery.Where(p => p.IsApproved); } } var posts = await dataQuery .OrderByDescending(p => p.UploadedAt) .Skip((page - 1) * pageSize) .Take(pageSize) .AsSplitQuery() .ToListAsync(); var bointsEnabled = await _siteSettings.GetBoolAsync("boints.enabled", false); // On page 1, prepend boosted posts if (page == 1 && bointsEnabled) { var now = DateTime.UtcNow; var boostedPosts = await dataQuery .Where(p => p.BoostedUntil != null && p.BoostedUntil > now) .OrderByDescending(p => p.BoostedUntil) .Take(5) .AsSplitQuery() .ToListAsync(); if (boostedPosts.Count > 0) { var boostedIds = boostedPosts.Select(p => p.Id).ToHashSet(); posts = boostedPosts .Concat(posts.Where(p => !boostedIds.Contains(p.Id))) .ToList(); } } // Batch-fetch display info for all uploaders var uploaderIds = posts .Where(p => p.Uploader != null) .Select(p => p.Uploader!.Id) .Distinct(); var displayInfoMap = await _userBadgeService.GetUsersDisplayInfoAsync(uploaderIds); sw.Stop(); return new SearchResult { Posts = new PagedResult { Items = posts.ToDto(displayInfoMap), TotalCount = totalCount, Page = page, PageSize = pageSize }, Metadata = new SearchMetadata { ParsedQuery = string.Empty, Warnings = [], QueryTimeMs = sw.Elapsed.TotalMilliseconds } }; } private SearchResult CreateEmptyResult(string query, List warnings, Stopwatch sw) { sw.Stop(); return new SearchResult { Posts = new PagedResult { Items = [], TotalCount = 0, Page = 1, PageSize = 20 }, Metadata = new SearchMetadata { ParsedQuery = query, Warnings = warnings, QueryTimeMs = sw.Elapsed.TotalMilliseconds } }; } /// /// Returns a cached parse result for the given query string, or tokenizes+parses and caches it. /// private SearchParseResult GetOrParseQuery(string query) { var cacheKey = $"search_parse:{query}"; if (_cache.TryGetValue(cacheKey, out SearchParseResult? cached)) return cached!; var tokenizer = new SearchTokenizer(query); var tokens = tokenizer.Tokenize(); var parser = new SearchParser(tokens); var parseResult = parser.Parse(); if (parseResult.Errors.Count == 0) { _cache.Set(cacheKey, parseResult, ParseCacheDuration); } return parseResult; } }