using System.Collections.Immutable; using System.Diagnostics; using System.Text.Json.Nodes; using Microsoft.Extensions.Logging; using StellaOps.Notify.Engine; using StellaOps.Notify.Models; using StellaOps.Notifier.Worker.Storage; using StellaOps.Notifier.Worker.Correlation; namespace StellaOps.Notifier.Worker.Simulation; /// /// Default implementation of the notification simulation engine. /// Dry-runs rules against events to preview what actions would be triggered. /// public sealed class DefaultNotifySimulationEngine : INotifySimulationEngine { private readonly INotifyRuleRepository _ruleRepository; private readonly INotifyChannelRepository _channelRepository; private readonly INotifyAuditRepository _auditRepository; private readonly INotifyRuleEvaluator _ruleEvaluator; private readonly INotifyThrottler? _throttler; private readonly IQuietHoursEvaluator? _quietHoursEvaluator; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; private static readonly TimeSpan DefaultThrottleWindow = TimeSpan.FromMinutes(5); public DefaultNotifySimulationEngine( INotifyRuleRepository ruleRepository, INotifyChannelRepository channelRepository, INotifyAuditRepository auditRepository, INotifyRuleEvaluator ruleEvaluator, INotifyThrottler? throttler, IQuietHoursEvaluator? quietHoursEvaluator, TimeProvider timeProvider, ILogger logger) { _ruleRepository = ruleRepository ?? throw new ArgumentNullException(nameof(ruleRepository)); _channelRepository = channelRepository ?? throw new ArgumentNullException(nameof(channelRepository)); _auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository)); _ruleEvaluator = ruleEvaluator ?? throw new ArgumentNullException(nameof(ruleEvaluator)); _throttler = throttler; _quietHoursEvaluator = quietHoursEvaluator; _timeProvider = timeProvider ?? TimeProvider.System; _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task SimulateAsync( NotifySimulationRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); var stopwatch = Stopwatch.StartNew(); var simulationId = Guid.NewGuid().ToString("N"); var evaluationTime = request.EvaluationTimestamp ?? _timeProvider.GetUtcNow(); _logger.LogInformation( "Starting simulation {SimulationId} for tenant {TenantId}: period {PeriodStart} to {PeriodEnd}", simulationId, request.TenantId, request.PeriodStart, request.PeriodEnd); // Load rules var allRules = await _ruleRepository.ListAsync(request.TenantId, cancellationToken).ConfigureAwait(false); var rules = FilterRules(allRules, request.RuleIds); _logger.LogDebug( "Simulation {SimulationId}: loaded {RuleCount} rules ({FilteredCount} after filtering)", simulationId, allRules.Count, rules.Count); // Load historical events from audit log var auditEntries = await _auditRepository.QueryAsync( request.TenantId, request.PeriodStart, request.MaxEvents, cancellationToken).ConfigureAwait(false); // Convert audit entries to events for simulation var events = ConvertAuditEntriesToEvents(auditEntries, request.PeriodStart, request.PeriodEnd, request.EventKinds); _logger.LogDebug( "Simulation {SimulationId}: loaded {EventCount} events from audit log", simulationId, events.Count); // Load channels for action evaluation var channels = await LoadChannelsAsync(request.TenantId, rules, cancellationToken).ConfigureAwait(false); // Run simulation var eventResults = new List(); var ruleSummaries = new Dictionary(StringComparer.Ordinal); foreach (var rule in rules) { ruleSummaries[rule.RuleId] = new RuleSummaryBuilder(rule); } foreach (var @event in events) { cancellationToken.ThrowIfCancellationRequested(); var eventResult = await SimulateEventAsync( @event, rules, channels, request, evaluationTime, ruleSummaries, cancellationToken).ConfigureAwait(false); eventResults.Add(eventResult); } stopwatch.Stop(); var result = new NotifySimulationResult { SimulationId = simulationId, TenantId = request.TenantId, SimulatedAt = _timeProvider.GetUtcNow(), EventsEvaluated = events.Count, RulesEvaluated = rules.Count, TotalMatches = eventResults.Sum(e => e.MatchedRules), TotalActions = eventResults.Sum(e => e.TriggeredActions), EventResults = eventResults.ToImmutableArray(), RuleSummaries = ruleSummaries.Values .Select(b => b.Build()) .OrderByDescending(s => s.MatchCount) .ToImmutableArray(), Duration = stopwatch.Elapsed }; _logger.LogInformation( "Completed simulation {SimulationId}: {EventsEvaluated} events, {TotalMatches} matches, {TotalActions} actions in {Duration}ms", simulationId, result.EventsEvaluated, result.TotalMatches, result.TotalActions, result.Duration.TotalMilliseconds); return result; } public async Task SimulateSingleEventAsync( string tenantId, JsonObject eventPayload, IEnumerable? ruleIds = null, DateTimeOffset? evaluationTimestamp = null, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); ArgumentNullException.ThrowIfNull(eventPayload); var evaluationTime = evaluationTimestamp ?? _timeProvider.GetUtcNow(); // Parse event from payload var @event = ParseEventFromPayload(tenantId, eventPayload); // Load rules var allRules = await _ruleRepository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false); var rules = FilterRules(allRules, ruleIds?.ToImmutableArray() ?? []); // Load channels var channels = await LoadChannelsAsync(tenantId, rules, cancellationToken).ConfigureAwait(false); // Create dummy request for simulation var request = new NotifySimulationRequest { TenantId = tenantId, PeriodStart = evaluationTime.AddHours(-1), PeriodEnd = evaluationTime, EvaluationTimestamp = evaluationTime, EvaluateThrottling = true, EvaluateQuietHours = true, IncludeNonMatches = true }; var ruleSummaries = new Dictionary(StringComparer.Ordinal); return await SimulateEventAsync(@event, rules, channels, request, evaluationTime, ruleSummaries, cancellationToken) .ConfigureAwait(false); } private async Task SimulateEventAsync( NotifyEvent @event, IReadOnlyList rules, IReadOnlyDictionary channels, NotifySimulationRequest request, DateTimeOffset evaluationTime, Dictionary ruleSummaries, CancellationToken cancellationToken) { var matches = new List(); var nonMatches = new List(); foreach (var rule in rules) { var outcome = _ruleEvaluator.Evaluate(rule, @event, evaluationTime); if (outcome.IsMatch) { var actionResults = await EvaluateActionsAsync( @event, rule, outcome.Actions, channels, request, evaluationTime, cancellationToken).ConfigureAwait(false); var explanations = BuildMatchExplanations(rule, @event); matches.Add(new SimulatedRuleMatch { RuleId = rule.RuleId, RuleName = rule.Name ?? rule.RuleId, Priority = 0, // NotifyRule doesn't have priority, default to 0 MatchedAt = outcome.MatchedAt ?? evaluationTime, Actions = actionResults, MatchExplanations = explanations }); if (ruleSummaries.TryGetValue(rule.RuleId, out var summary)) { summary.RecordMatch(actionResults.Length); } } else if (request.IncludeNonMatches) { var explanation = BuildNonMatchExplanation(outcome.Reason ?? "unknown", rule, @event); nonMatches.Add(new SimulatedRuleNonMatch { RuleId = rule.RuleId, RuleName = rule.Name ?? rule.RuleId, Reason = outcome.Reason ?? "unknown", Explanation = explanation }); if (ruleSummaries.TryGetValue(rule.RuleId, out var summary)) { summary.RecordNonMatch(outcome.Reason ?? "unknown"); } } } return new SimulatedEventResult { EventId = @event.EventId, Kind = @event.Kind, EventTimestamp = @event.Ts, MatchedRules = matches.Count, TriggeredActions = matches.Sum(m => m.Actions.Count(a => a.WouldDeliver)), Matches = matches.OrderBy(m => m.Priority).ToImmutableArray(), NonMatches = nonMatches.ToImmutableArray() }; } private async Task> EvaluateActionsAsync( NotifyEvent @event, NotifyRule rule, ImmutableArray actions, IReadOnlyDictionary channels, NotifySimulationRequest request, DateTimeOffset evaluationTime, CancellationToken cancellationToken) { var results = new List(); foreach (var action in actions) { if (!action.Enabled) { continue; } var channelId = action.Channel?.Trim() ?? string.Empty; channels.TryGetValue(channelId, out var channel); var wouldDeliver = true; var deliveryExplanation = "Would be delivered successfully"; string? throttleReason = null; string? quietHoursReason = null; string? channelBlockReason = null; // Check channel availability if (channel is null) { wouldDeliver = false; channelBlockReason = $"Channel '{channelId}' not found"; deliveryExplanation = channelBlockReason; } else if (!channel.Enabled) { wouldDeliver = false; channelBlockReason = $"Channel '{channelId}' is disabled"; deliveryExplanation = channelBlockReason; } // Check throttling if (wouldDeliver && request.EvaluateThrottling && _throttler is not null) { var throttleKey = $"{rule.RuleId}:{action.ActionId}:{@event.Kind}"; var throttleWindow = action.Throttle is { Ticks: > 0 } ? action.Throttle.Value : DefaultThrottleWindow; var throttleResult = await _throttler.CheckAsync( @event.Tenant, throttleKey, throttleWindow, null, cancellationToken).ConfigureAwait(false); if (throttleResult.IsThrottled) { wouldDeliver = false; throttleReason = $"Would be throttled (key: {throttleKey})"; deliveryExplanation = throttleReason; } } // Check quiet hours if (wouldDeliver && request.EvaluateQuietHours && _quietHoursEvaluator is not null) { var quietHoursResult = await _quietHoursEvaluator.EvaluateAsync( @event.Tenant, @event.Kind, cancellationToken).ConfigureAwait(false); if (quietHoursResult.IsSuppressed) { wouldDeliver = false; quietHoursReason = quietHoursResult.Reason ?? "In quiet hours period"; deliveryExplanation = quietHoursReason; } } if (wouldDeliver) { deliveryExplanation = $"Would deliver to {channel?.Type.ToString() ?? "unknown"} channel '{channelId}'"; if (!string.IsNullOrWhiteSpace(action.Template)) { deliveryExplanation += $" using template '{action.Template}'"; } } results.Add(new SimulatedActionResult { ActionId = action.ActionId, ChannelId = channelId, ChannelType = channel?.Type ?? NotifyChannelType.Custom, TemplateId = action.Template, WouldDeliver = wouldDeliver, DeliveryExplanation = deliveryExplanation, ThrottleReason = throttleReason, QuietHoursReason = quietHoursReason, ChannelBlockReason = channelBlockReason }); } return results.ToImmutableArray(); } private static ImmutableArray BuildMatchExplanations(NotifyRule rule, NotifyEvent @event) { var explanations = new List(); var match = rule.Match; if (!match.EventKinds.IsDefaultOrEmpty) { explanations.Add($"Event kind '{@event.Kind}' matched filter [{string.Join(", ", match.EventKinds)}]"); } else { explanations.Add("Event kind matched (no filter specified)"); } if (!match.Namespaces.IsDefaultOrEmpty && !string.IsNullOrWhiteSpace(@event.Scope?.Namespace)) { explanations.Add($"Namespace '{@event.Scope.Namespace}' matched filter"); } if (!match.Repositories.IsDefaultOrEmpty && !string.IsNullOrWhiteSpace(@event.Scope?.Repo)) { explanations.Add($"Repository '{@event.Scope.Repo}' matched filter"); } if (!string.IsNullOrWhiteSpace(match.MinSeverity)) { explanations.Add($"Severity met minimum threshold of '{match.MinSeverity}'"); } if (!match.Labels.IsDefaultOrEmpty) { explanations.Add($"Labels matched required set: [{string.Join(", ", match.Labels)}]"); } return explanations.ToImmutableArray(); } private static string BuildNonMatchExplanation(string reason, NotifyRule rule, NotifyEvent @event) { return reason switch { "rule_disabled" => $"Rule '{rule.Name ?? rule.RuleId}' is disabled", "event_kind_mismatch" => $"Event kind '{@event.Kind}' not in rule filter [{string.Join(", ", rule.Match.EventKinds)}]", "namespace_mismatch" => $"Namespace '{@event.Scope?.Namespace ?? "(none)"}' not in rule filter [{string.Join(", ", rule.Match.Namespaces)}]", "repository_mismatch" => $"Repository '{@event.Scope?.Repo ?? "(none)"}' not in rule filter [{string.Join(", ", rule.Match.Repositories)}]", "digest_mismatch" => $"Digest '{@event.Scope?.Digest ?? "(none)"}' not in rule filter", "component_mismatch" => "Event component PURLs did not match rule filter", "kev_required" => "Rule requires KEV label but event does not have it", "label_mismatch" => $"Event labels did not match required set [{string.Join(", ", rule.Match.Labels)}]", "severity_below_threshold" => $"Event severity below minimum '{rule.Match.MinSeverity}'", "verdict_mismatch" => $"Event verdict not in rule filter [{string.Join(", ", rule.Match.Verdicts)}]", "no_enabled_actions" => "Rule has no enabled actions", _ => $"Rule did not match: {reason}" }; } private static IReadOnlyList FilterRules( IReadOnlyList rules, ImmutableArray ruleIds) { if (ruleIds.IsDefaultOrEmpty) { return rules.Where(r => r.Enabled).ToList(); } var ruleIdSet = ruleIds.ToHashSet(StringComparer.OrdinalIgnoreCase); return rules.Where(r => ruleIdSet.Contains(r.RuleId)).ToList(); } private async Task> LoadChannelsAsync( string tenantId, IReadOnlyList rules, CancellationToken cancellationToken) { var channelIds = rules .SelectMany(r => r.Actions) .Where(a => !string.IsNullOrWhiteSpace(a.Channel)) .Select(a => a.Channel!.Trim()) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); var channels = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var channelId in channelIds) { var channel = await _channelRepository.GetAsync(tenantId, channelId, cancellationToken).ConfigureAwait(false); if (channel is not null) { channels[channelId] = channel; } } return channels; } private static IReadOnlyList ConvertAuditEntriesToEvents( IReadOnlyList auditEntries, DateTimeOffset periodStart, DateTimeOffset periodEnd, ImmutableArray eventKinds) { var kindSet = eventKinds.IsDefaultOrEmpty ? null : eventKinds.ToHashSet(StringComparer.OrdinalIgnoreCase); var events = new List(); foreach (var entry in auditEntries) { if (entry.Timestamp < periodStart || entry.Timestamp >= periodEnd) { continue; } var eventKind = ExtractEventKindFromAuditEntry(entry); if (string.IsNullOrWhiteSpace(eventKind)) { continue; } if (kindSet is not null && !kindSet.Contains(eventKind)) { continue; } var eventId = ExtractEventIdFromAuditEntry(entry); var payload = ToPayload(entry.Data); var @event = NotifyEvent.Create( eventId: eventId, kind: eventKind, tenant: entry.TenantId, ts: entry.Timestamp, payload: payload); events.Add(@event); } return events; } private static string? ExtractEventKindFromAuditEntry(NotifyAuditEntry entry) { // The event kind might be encoded in the action field or payload // Action format is typically "event.kind.action" or we look in payload var action = entry.Action; // Try to extract from action (e.g., "pack.approval.ingested" -> "pack.approval") if (!string.IsNullOrWhiteSpace(action)) { var parts = action.Split('.', StringSplitOptions.RemoveEmptyEntries); if (parts.Length >= 2) { return string.Join(".", parts.Take(parts.Length - 1)); } } // Try to extract from payload if (entry.Data.TryGetValue("Kind", out var kind) || entry.Data.TryGetValue("kind", out kind)) { return kind; } return null; } private static Guid ExtractEventIdFromAuditEntry(NotifyAuditEntry entry) { if (entry.Data.TryGetValue("eventId", out var eventId) && Guid.TryParse(eventId, out var parsed)) { return parsed; } return Guid.NewGuid(); } private static JsonNode ToPayload(IReadOnlyDictionary data) { var obj = new JsonObject(); foreach (var (key, value) in data) { obj[key] = value; } return obj; } private static NotifyEvent ParseEventFromPayload(string tenantId, JsonObject payload) { var eventId = payload.TryGetPropertyValue("eventId", out var idNode) && idNode is JsonValue idValue ? (Guid.TryParse(idValue.ToString(), out var id) ? id : Guid.NewGuid()) : Guid.NewGuid(); var kind = payload.TryGetPropertyValue("kind", out var kindNode) && kindNode is JsonValue kindValue ? kindValue.ToString() : "simulation.test"; var ts = payload.TryGetPropertyValue("ts", out var tsNode) && tsNode is JsonValue tsValue && DateTimeOffset.TryParse(tsValue.ToString(), out var timestamp) ? timestamp : DateTimeOffset.UtcNow; var eventPayload = payload.TryGetPropertyValue("payload", out var payloadNode) ? payloadNode : payload; NotifyEventScope? scope = null; if (payload.TryGetPropertyValue("scope", out var scopeNode) && scopeNode is JsonObject scopeObj) { scope = NotifyEventScope.Create( @namespace: GetStringProperty(scopeObj, "namespace"), repo: GetStringProperty(scopeObj, "repo"), digest: GetStringProperty(scopeObj, "digest"), component: GetStringProperty(scopeObj, "component"), image: GetStringProperty(scopeObj, "image")); } var attributes = ImmutableDictionary.Empty; if (payload.TryGetPropertyValue("attributes", out var attrNode) && attrNode is JsonObject attrObj) { var builder = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); foreach (var prop in attrObj) { if (prop.Value is JsonValue value) { builder[prop.Key] = value.ToString(); } } attributes = builder.ToImmutable(); } return NotifyEvent.Create( eventId: eventId, kind: kind, tenant: tenantId, ts: ts, payload: eventPayload, scope: scope, attributes: attributes); } private static string? GetStringProperty(JsonObject obj, string name) { return obj.TryGetPropertyValue(name, out var node) && node is JsonValue value ? value.ToString() : null; } private sealed class RuleSummaryBuilder { private readonly NotifyRule _rule; private int _matchCount; private int _actionCount; private readonly Dictionary _nonMatchReasons = new(StringComparer.Ordinal); public RuleSummaryBuilder(NotifyRule rule) { _rule = rule; } public void RecordMatch(int actions) { _matchCount++; _actionCount += actions; } public void RecordNonMatch(string reason) { _nonMatchReasons.TryGetValue(reason, out var count); _nonMatchReasons[reason] = count + 1; } public SimulatedRuleSummary Build() { return new SimulatedRuleSummary { RuleId = _rule.RuleId, RuleName = _rule.Name ?? _rule.RuleId, MatchCount = _matchCount, ActionCount = _actionCount, NonMatchReasons = _nonMatchReasons.ToImmutableDictionary() }; } } }