using System.Data; using System.Security.Cryptography; using System.Text; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Tiku.Application.Content; using Tiku.Application.Security; using Tiku.Domain.Content; using Tiku.Domain.Learning; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.ContentV2; internal sealed class ContentReleaseCompiler( IQuestionBankPersistence content, ILearningPersistence learning, ITenantExecutionScope tenantExecutionScope) : IContentReleaseCompiler { internal const string ReleasePublishedEvent = "content_release_published"; public async Task PublishAsync( PublishContentReleaseCommand command, CancellationToken cancellationToken = default) { await using var transaction = await content.Database.BeginTransactionAsync( IsolationLevel.RepeatableRead, cancellationToken); var publicationLockKey = $"content-release:{command.TenantId:N}:{command.CurriculumVersionId:N}"; await content.Database.ExecuteSqlInterpolatedAsync( $"SELECT pg_advisory_xact_lock(hashtextextended({publicationLockKey}, 0))", cancellationToken); var curriculumVersion = await content.CurriculumVersions.AsNoTracking().SingleOrDefaultAsync( item => item.TenantId == command.TenantId && item.Id == command.CurriculumVersionId && item.Status == ContentDefinitionStatus.Published, cancellationToken) ?? throw Error("curriculum_version_not_publishable", "A published curriculum version is required."); var curriculum = await content.Curricula.AsNoTracking().SingleAsync( item => item.TenantId == command.TenantId && item.Id == curriculumVersion.CurriculumId, cancellationToken); var placements = await content.QuestionPlacements.AsNoTracking() .Where(item => item.TenantId == command.TenantId && item.CurriculumVersionId == curriculumVersion.Id && item.Status == QuestionPlacementStatus.Active && (item.ValidFrom == null || item.ValidFrom <= DateTimeOffset.UtcNow) && (item.ValidTo == null || item.ValidTo > DateTimeOffset.UtcNow)) .OrderBy(item => item.Id) .ToArrayAsync(cancellationToken); if (placements.Length == 0) throw Error("content_release_empty", "The curriculum has no active question placements."); var platformOwnerIds = await tenantExecutionScope.ExecuteAsync( new SystemScopeRequest( null, SystemScopeCallerType.Platform, nameof(ContentReleaseCompiler), "Resolve the unique platform V2 content owner", Guid.NewGuid().ToString("N"), true), (provider, token) => provider.GetRequiredService().Tenants.AsNoTracking() .Where(item => item.Mode == TenantMode.PlatformOwned) .Select(item => item.Id) .ToArrayAsync(token), cancellationToken); if (platformOwnerIds.Length != 1) throw Error("platform_content_owner_invalid", "Exactly one platform-owned content tenant is required before publishing content."); var permittedOwnerIds = platformOwnerIds.Append(command.TenantId).Distinct().ToArray(); if (placements.Any(item => !permittedOwnerIds.Contains(item.QuestionAssetOwnerTenantId))) throw Error("question_asset_owner_forbidden", "A placement may reference only this tenant's private question or the platform public owner."); var assetIds = placements.Select(item => item.QuestionAssetId).Distinct().ToArray(); var assets = new List(); foreach (var ownerTenantId in permittedOwnerIds) assets.AddRange(await ReadForOwnerAsync( ownerTenantId, "Load explicitly placed published V2 question assets", (persistence, token) => persistence.QuestionAssets.AsNoTracking() .Where(item => item.TenantId == ownerTenantId && assetIds.Contains(item.Id) && item.Status == QuestionAssetStatus.Published && item.CurrentRevisionId != null) .ToArrayAsync(token), cancellationToken)); var assetsByOwner = assets.ToDictionary(item => (item.TenantId, item.Id)); var requestedAssets = placements .Select(item => (item.QuestionAssetOwnerTenantId, item.QuestionAssetId)) .Distinct() .ToArray(); if (requestedAssets.Any(key => !assetsByOwner.ContainsKey(key))) throw Error("content_release_question_unpublished", "Every placement must reference a published question asset with a current revision."); var revisionIds = assets.Select(item => item.CurrentRevisionId!.Value).Distinct().ToArray(); var revisions = new List(); foreach (var ownerTenantId in permittedOwnerIds) revisions.AddRange(await ReadForOwnerAsync( ownerTenantId, "Load immutable V2 question revisions for release compilation", (persistence, token) => persistence.QuestionRevisions.AsNoTracking() .Where(item => item.TenantId == ownerTenantId && revisionIds.Contains(item.Id)) .ToArrayAsync(token), cancellationToken)); var revisionsByOwner = revisions.ToDictionary(item => (item.TenantId, item.Id)); if (requestedAssets.Any(key => !revisionsByOwner.ContainsKey((key.QuestionAssetOwnerTenantId, assetsByOwner[key].CurrentRevisionId!.Value)))) throw Error("content_release_revision_missing", "One or more immutable question revisions are missing."); var policyVersionIds = placements.Select(item => item.AssessmentPolicyVersionId).Distinct().ToArray(); var publishedPolicyIds = await content.AssessmentPolicyVersions.AsNoTracking() .Where(item => item.TenantId == command.TenantId && policyVersionIds.Contains(item.Id) && item.Status == ContentDefinitionStatus.Published) .Select(item => item.Id) .ToArrayAsync(cancellationToken); if (publishedPolicyIds.Length != policyVersionIds.Length) throw Error("content_release_assessment_unpublished", "Every placement must reference a published assessment policy version."); var profileVersions = await ( from profile in content.ExamTargetProfiles.AsNoTracking() join version in content.ExamTargetProfileVersions.AsNoTracking() on profile.Id equals version.ExamTargetProfileId where profile.BusinessLineId == curriculum.BusinessLineId && profile.IsActive && version.Status == ContentDefinitionStatus.Published select version) .ToArrayAsync(cancellationToken); if (profileVersions.Length == 0) throw Error("content_release_target_profiles_missing", "At least one published exam target profile is required."); var profileFacts = await LoadProfileFactsAsync(profileVersions, cancellationToken); var placementRules = await LoadPlacementRulesAsync(placements, cancellationToken); var placementMatches = placements.Select(placement => { var groups = placementRules.GetValueOrDefault(placement.Id) ?? []; var matchingProfiles = profileFacts .Where(pair => QuestionApplicabilityEvaluator.Matches(pair.Value, groups)) .Select(pair => pair.Key) .Order() .ToArray(); if (matchingProfiles.Length == 0) throw Error("content_release_rule_matches_nothing", $"Placement '{placement.Id}' does not match any published target profile."); return new PlacementMatch(placement, matchingProfiles, HashIds(matchingProfiles)); }).ToArray(); var now = DateTimeOffset.UtcNow; var nextReleaseNo = await content.ContentReleases .Where(item => item.TenantId == command.TenantId && item.CurriculumVersionId == curriculumVersion.Id) .Select(item => (int?)item.ReleaseNo) .MaxAsync(cancellationToken) ?? 0; var sourceFingerprint = ComputeSourceFingerprint(placementMatches, assetsByOwner, revisionsByOwner); var release = new ContentRelease { TenantId = command.TenantId, BusinessLineId = curriculum.BusinessLineId, CurriculumVersionId = curriculumVersion.Id, ReleaseNo = nextReleaseNo + 1, Name = command.Name.Trim(), Status = ContentReleaseStatus.Compiling, SourceFingerprint = sourceFingerprint, PublishedAt = now, CreatedBy = command.ActorUserId }; content.ContentReleases.Add(release); await content.SaveChangesAsync(cancellationToken); var segmentsByHash = new Dictionary(StringComparer.Ordinal); foreach (var match in placementMatches) { if (segmentsByHash.ContainsKey(match.AudienceHash)) continue; var segment = new AudienceSegment { TenantId = command.TenantId, ContentReleaseId = release.Id, RuleHash = match.AudienceHash, SegmentOrdinal = segmentsByHash.Count }; segmentsByHash.Add(match.AudienceHash, segment); content.AudienceSegments.Add(segment); foreach (var profileVersionId in match.ProfileVersionIds) content.AudienceSegmentMembers.Add(new AudienceSegmentMember { TenantId = command.TenantId, ContentReleaseId = release.Id, AudienceSegmentId = segment.Id, ExamTargetProfileVersionId = profileVersionId }); } foreach (var group in placementMatches .OrderBy(item => item.Placement.CurriculumNodeId) .ThenBy(item => assetsByOwner[(item.Placement.QuestionAssetOwnerTenantId, item.Placement.QuestionAssetId)].DeliveryFingerprint) .GroupBy(item => new { item.AudienceHash, item.Placement.CurriculumNodeId })) { var ordinal = 0; foreach (var match in group) { var asset = assetsByOwner[(match.Placement.QuestionAssetOwnerTenantId, match.Placement.QuestionAssetId)]; var revision = revisionsByOwner[(asset.TenantId, asset.CurrentRevisionId!.Value)]; content.ContentReleaseQuestions.Add(new ContentReleaseQuestion { TenantId = command.TenantId, ContentReleaseId = release.Id, AudienceSegmentId = segmentsByHash[match.AudienceHash].Id, CurriculumNodeId = match.Placement.CurriculumNodeId, QuestionPlacementId = match.Placement.Id, QuestionAssetOwnerTenantId = asset.TenantId, QuestionAssetId = asset.Id, QuestionRevisionId = revision.Id, AssessmentPolicyVersionId = match.Placement.AssessmentPolicyVersionId, Ordinal = ordinal++, Difficulty = match.Placement.DifficultyOverride ?? revision.Difficulty, QuestionType = revision.QuestionType, Status = ContentReleaseQuestionStatus.Active }); } } await content.SaveChangesAsync(cancellationToken); release.Status = ContentReleaseStatus.Published; learning.LearningOutboxMessages.Add(new LearningOutboxMessage { TenantId = command.TenantId, EventType = ReleasePublishedEvent, SchemaVersion = 1, AggregateId = release.Id, Payload = JsonSerializer.SerializeToElement(new { releaseId = release.Id, curriculumVersionId = curriculumVersion.Id, businessLineId = curriculum.BusinessLineId, releaseNo = release.ReleaseNo, sourceFingerprint }), OccurredAt = now, AvailableAt = now }); await content.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); return new ContentReleaseCompilationResult( release.Id, release.ReleaseNo, placements.Length, placements.Length, segmentsByHash.Count, profileVersions.Length, sourceFingerprint, now); } private async Task>> LoadProfileFactsAsync( IReadOnlyCollection versions, CancellationToken cancellationToken) { var versionIds = versions.Select(item => item.Id).ToArray(); var values = await content.ExamTargetProfileValues.AsNoTracking() .Where(item => versionIds.Contains(item.ExamTargetProfileVersionId)) .ToArrayAsync(cancellationToken); var nodeIds = values.Select(item => item.TargetNodeId).Distinct().ToArray(); var allNodes = await content.TargetNodes.AsNoTracking() .Select(item => new NodeParent(item.Id, item.ParentId)) .ToDictionaryAsync(item => item.Id, cancellationToken); var ancestors = nodeIds.ToDictionary(nodeId => nodeId, nodeId => ResolveAncestors(nodeId, allNodes)); return versions.ToDictionary( version => version.Id, version => (IReadOnlyCollection)values .Where(value => value.ExamTargetProfileVersionId == version.Id) .Select(value => new TargetProfileFact( value.TargetDimensionDefinitionId, value.TargetNodeId, ancestors[value.TargetNodeId])) .ToArray()); } private async Task>> LoadPlacementRulesAsync( IReadOnlyCollection placements, CancellationToken cancellationToken) { var placementIds = placements.Select(item => item.Id).ToArray(); var groups = await content.QuestionPlacementRuleGroups.AsNoTracking() .Where(item => item.TenantId == placements.First().TenantId && placementIds.Contains(item.PlacementId)) .ToArrayAsync(cancellationToken); var groupIds = groups.Select(item => item.Id).ToArray(); var conditions = await content.QuestionPlacementRuleConditions.AsNoTracking() .Where(item => item.TenantId == placements.First().TenantId && groupIds.Contains(item.RuleGroupId)) .ToArrayAsync(cancellationToken); return groups.GroupBy(item => item.PlacementId).ToDictionary( group => group.Key, group => (IReadOnlyCollection)group.OrderBy(item => item.GroupOrder) .Select(item => new PlacementRuleGroupFact( item.GroupOrder, conditions.Where(condition => condition.RuleGroupId == item.Id) .Select(condition => new PlacementRuleConditionFact( condition.TargetDimensionDefinitionId, condition.TargetNodeId, condition.Operator)) .ToArray())) .ToArray()); } private static IReadOnlySet ResolveAncestors( Guid nodeId, IReadOnlyDictionary nodes) { var result = new HashSet(); var current = nodeId; while (nodes.TryGetValue(current, out var node) && node.ParentId is Guid parentId) { if (!result.Add(parentId)) throw Error("target_node_cycle", "A target-node hierarchy contains a cycle."); current = parentId; } return result; } private static string ComputeSourceFingerprint( IEnumerable matches, IReadOnlyDictionary<(Guid OwnerTenantId, Guid AssetId), QuestionAsset> assets, IReadOnlyDictionary<(Guid OwnerTenantId, Guid RevisionId), QuestionRevision> revisions) { var values = matches.OrderBy(item => item.Placement.Id).Select(item => { var asset = assets[(item.Placement.QuestionAssetOwnerTenantId, item.Placement.QuestionAssetId)]; var revision = revisions[(asset.TenantId, asset.CurrentRevisionId!.Value)]; return $"{item.Placement.Id:N}:{asset.TenantId:N}:{revision.Id:N}:{item.Placement.AssessmentPolicyVersionId:N}:{item.AudienceHash}"; }); return HashText(string.Join('|', values)); } private static string HashIds(IEnumerable ids) => HashText(string.Join(',', ids.Select(id => id.ToString("N")))); private static string HashText(string value) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); private static ContentV2Exception Error(string code, string message) => new(code, message); private Task ReadForOwnerAsync( Guid ownerTenantId, string reason, Func> read, CancellationToken cancellationToken) { return tenantExecutionScope.ExecuteAsync( new SystemScopeRequest( ownerTenantId, SystemScopeCallerType.PublicQuestionBank, nameof(ContentReleaseCompiler), reason, Guid.NewGuid().ToString("N")), (provider, token) => read(provider.GetRequiredService(), token), cancellationToken); } private sealed record PlacementMatch( QuestionPlacement Placement, Guid[] ProfileVersionIds, string AudienceHash); private sealed record NodeParent(Guid Id, Guid? ParentId); }