using Microsoft.EntityFrameworkCore; using Tiku.Application.Catalog; using Tiku.Application.Content; using Tiku.Domain.Common; using Tiku.Domain.Content; using Tiku.Domain.QuestionBanks; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Content; public sealed class ContentNavigationQueryService(TikuDbContext dbContext) : IContentNavigationQueryService { private const int DefaultLimit = 100; private const int MaxLimit = 1000; public async Task> GetContentEntriesAsync( ContentNavigationFilter filter, CancellationToken cancellationToken = default) { var query = dbContext.ContentEntries .AsNoTracking() .Where(entry => entry.TenantId == filter.TenantId && entry.IsActive); if (!filter.IncludeHidden) { query = query.Where(entry => entry.Visibility != ContentVisibility.Hidden); } if (filter.RegionId.HasValue) { query = query.Where(entry => entry.RegionId == filter.RegionId.Value || entry.RegionId == null); } if (TryParseEntryType(filter.EntryType, out var entryType)) { query = query.Where(entry => entry.EntryType == entryType); } query = ApplyKeyword(query, filter.Keyword); var items = await query .OrderBy(entry => entry.SortOrder) .ThenBy(entry => entry.CreatedAt) .Take(ResolveLimit(filter.Limit, 500)) .Select(entry => new ContentEntryCatalogItem( entry.Id, entry.RegionId, entry.LegacyId, entry.EntryKey, entry.Name, entry.EntryType, entry.Icon, entry.Route, entry.Description, entry.Visibility, entry.AccessRules, entry.LayoutConfig, entry.SortOrder)) .ToArrayAsync(cancellationToken); return new CatalogList(items); } public async Task> GetContentNodesAsync( ContentNavigationFilter filter, CancellationToken cancellationToken = default) { if (!filter.EntryId.HasValue) { throw new RequiredFieldException("entryId is required."); } var query = dbContext.ContentNodes .AsNoTracking() .Where(node => node.TenantId == filter.TenantId && node.EntryId == filter.EntryId.Value); if (!filter.IncludeInactive) { query = query.Where(node => node.IsActive); } if (filter.RegionId.HasValue) { query = query.Where(node => node.RegionId == filter.RegionId.Value || node.RegionId == null); } if (filter.ParentWasSpecified) { query = filter.ParentIsRoot ? query.Where(node => node.ParentId == null) : query.Where(node => node.ParentId == filter.ParentId); } if (TryParseMarkerType(filter.MarkerType, out var markerType)) { query = query.Where(node => node.MarkerType == markerType); } query = ApplyKeyword(query, filter.Keyword); query = string.Equals(filter.Mode, "flat", StringComparison.OrdinalIgnoreCase) ? query.OrderBy(node => node.Path).ThenBy(node => node.SortOrder) : query.OrderBy(node => node.SortOrder).ThenBy(node => node.CreatedAt); var items = await query .Take(ResolveLimit(filter.Limit, 1000)) .Select(node => new ContentNodeCatalogItem( node.Id, node.EntryId, node.RegionId, node.ParentId, node.LegacyId, node.NodeKey, node.Name, node.NodeType, node.MarkerType, node.MarkerConfig, node.Path, node.Depth, node.SortOrder, node.IsSelectable, node.IsLeaf, node.AccessRules, node.Metadata)) .ToArrayAsync(cancellationToken); return new CatalogList(items); } public async Task> GetQuestionCollectionsAsync( ContentNavigationFilter filter, CancellationToken cancellationToken = default) { var query = dbContext.QuestionCollections .AsNoTracking() .Where(collection => collection.TenantId == filter.TenantId && collection.Status == ContentStatus.Active); if (filter.RegionId.HasValue) { query = query.Where(collection => collection.RegionId == filter.RegionId.Value || collection.RegionId == null); } if (filter.EntryId.HasValue) { query = query.Where(collection => collection.EntryId == filter.EntryId.Value); } if (filter.NodeId.HasValue) { query = query.Where(collection => collection.NodeId == filter.NodeId.Value); } if (TryParseCollectionType(filter.CollectionType, out var collectionType)) { query = query.Where(collection => collection.CollectionType == collectionType); } query = ApplyKeyword(query, filter.Keyword); var items = await query .OrderBy(collection => collection.SortOrder) .ThenBy(collection => collection.CreatedAt) .Take(ResolveLimit(filter.Limit, 500)) .Select(collection => new QuestionCollectionCatalogItem( collection.Id, collection.RegionId, collection.EntryId, collection.NodeId, collection.SubjectId, collection.CategoryId, collection.QuestionBankId, collection.LegacyId, collection.Name, collection.CollectionType, collection.SourceType, collection.Filters, collection.QuestionCount, collection.TotalScore, collection.DurationMinutes, collection.Status, collection.SortOrder, collection.AccessRules, collection.Metadata)) .ToArrayAsync(cancellationToken); return new CatalogList(items); } public async Task> GetPracticeBlueprintsAsync( ContentNavigationFilter filter, CancellationToken cancellationToken = default) { var query = dbContext.PracticeBlueprints .AsNoTracking() .Where(blueprint => blueprint.TenantId == filter.TenantId && blueprint.Status == ContentStatus.Active); if (filter.RegionId.HasValue) { query = query.Where(blueprint => blueprint.RegionId == filter.RegionId.Value || blueprint.RegionId == null); } if (filter.EntryId.HasValue) { query = query.Where(blueprint => blueprint.EntryId == filter.EntryId.Value); } if (filter.NodeId.HasValue) { query = query.Where(blueprint => blueprint.NodeId == filter.NodeId.Value); } if (filter.CollectionId.HasValue) { query = query.Where(blueprint => blueprint.CollectionId == filter.CollectionId.Value); } if (TryParsePracticeMode(filter.Mode, out var mode)) { query = query.Where(blueprint => blueprint.Mode == mode); } query = ApplyKeyword(query, filter.Keyword); var items = await query .OrderBy(blueprint => blueprint.SortOrder) .ThenBy(blueprint => blueprint.CreatedAt) .Take(ResolveLimit(filter.Limit, 500)) .Select(blueprint => new PracticeBlueprintCatalogItem( blueprint.Id, blueprint.RegionId, blueprint.EntryId, blueprint.NodeId, blueprint.CollectionId, blueprint.LegacyId, blueprint.Name, blueprint.Mode, blueprint.AssemblyType, blueprint.QuestionLimit, blueprint.DurationMinutes, blueprint.TotalScore, blueprint.PassScore, blueprint.Sections, blueprint.Rules, blueprint.AccessRules, blueprint.Status, blueprint.SortOrder)) .ToArrayAsync(cancellationToken); return new CatalogList(items); } public async Task> GetCollectionQuestionsAsync( ContentNavigationFilter filter, CancellationToken cancellationToken = default) { if (!filter.CollectionId.HasValue) { throw new RequiredFieldException("collectionId is required."); } var collectionExists = await dbContext.QuestionCollections .AsNoTracking() .AnyAsync( collection => collection.TenantId == filter.TenantId && collection.Id == filter.CollectionId.Value && collection.Status == ContentStatus.Active, cancellationToken); if (!collectionExists) { throw new ContentNavigationNotFoundException("Question collection was not found."); } var emptyOptions = JsonDefaults.Array(); var emptyCorrectOptionIndices = JsonDefaults.Array(); var emptySubQuestions = JsonDefaults.Array(); var query = from item in dbContext.QuestionCollectionItems.AsNoTracking() join question in dbContext.Questions.AsNoTracking() on new { item.TenantId, QuestionId = item.QuestionId } equals new { question.TenantId, QuestionId = question.Id } join version in dbContext.QuestionVersions.AsNoTracking() on new { question.TenantId, QuestionId = question.Id, VersionId = question.CurrentVersionId } equals new { version.TenantId, version.QuestionId, VersionId = (Guid?)version.Id } into versions from version in versions.DefaultIfEmpty() where item.TenantId == filter.TenantId && item.CollectionId == filter.CollectionId.Value && question.Status == QuestionStatus.Published orderby item.SectionKey, item.SortOrder, question.CreatedAt select new CollectionQuestionCatalogItem( question.Id, question.LegacyId, question.EntryId, question.ContentNodeId, question.PrimaryCollectionId, question.SubjectId, question.CategoryId, question.NodeId, question.Type, question.TypeLabel, question.Difficulty, question.Tags, question.ExamMarkers, question.MediaUrl, question.HasVideoExplanation, item.SectionKey, item.Score, item.SortOrder, version == null ? null : version.Id, version == null ? null : version.Content, version == null ? emptyOptions : version.Options, version == null ? null : version.CorrectOptionIndex, version == null ? emptyCorrectOptionIndices : version.CorrectOptionIndices, version == null ? null : version.AnswerText, version == null ? null : version.Explanation, version == null ? emptySubQuestions : version.SubQuestions, version == null ? null : version.CodeLang, version == null ? null : version.CodeTemplate); var items = await query .Take(ResolveLimit(filter.Limit, MaxLimit)) .ToArrayAsync(cancellationToken); return new CatalogList(items); } private static IQueryable ApplyKeyword(IQueryable query, string? keyword) where T : class { if (string.IsNullOrWhiteSpace(keyword)) { return query; } var trimmed = keyword.Trim(); return query.Where(entity => EF.Property(entity, nameof(ContentEntry.Name)).Contains(trimmed)); } private static int ResolveLimit(int? limit, int defaultLimit) { return Math.Clamp(limit ?? defaultLimit, 1, MaxLimit); } private static bool TryParseEntryType(string? value, out ContentEntryType type) { return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out type); } private static bool TryParseCollectionType(string? value, out QuestionCollectionType type) { return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out type); } private static bool TryParsePracticeMode(string? value, out PracticeMode mode) { return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out mode); } private static bool TryParseMarkerType(string? value, out ContentMarkerType type) { return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out type); } private static string? NormalizeEnumValue(string? value) { return string.IsNullOrWhiteSpace(value) ? null : value.Replace("_", string.Empty, StringComparison.Ordinal) .Replace("-", string.Empty, StringComparison.Ordinal); } } public sealed class RequiredFieldException(string message) : Exception(message); public sealed class ContentNavigationNotFoundException(string message) : Exception(message);