using System; using System.Collections.Generic; using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using GB5Shared.GOP.Qualifier.Executors; using GB5Shared.GOP.Qualifier; using GB5Shared.GOP.Qualifier.DTOs; using GB5Shared.DTO.Framework.Login; using GB5Shared.DTO.Qualifier; using GB5Shared.Telemetry; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace GB5Shared.GOP.Qualifier { // ============================================================ // IQualifierFacade (new contract — replaces stub in GB5Shared) // ============================================================ public interface IQualifierFacadeImpl { /// /// Executes all active qualifiers bound to entityCode+stage+scope in /// topological order. Parallel qualifiers at the same DAG level run /// concurrently via Task.WhenAll. /// Returns an aggregated QualifierResult with all findings and the /// final hierarchical FactBag export. /// Task ExecuteStageAsync( QualifierExecutionContext ctx, LoginDTO loginDTO, CancellationToken ct = default); } // ============================================================ // QualifierFacadeImpl — Full DAG Execution // // Execution algorithm: // 1. Load entity bindings for (EntityCode, Stage, Scope). // 2. Load all active qualifier plans from L2 IMemoryCache. // Cache miss → load from DB, deserialize, store in cache. // 3. Load dependency graph → topological sort. // 4. Execute levels in order; within a level, parallel // qualifiers run via Task.WhenAll. // 5. For each qualifier: run all methods (NCalc/DS/Service) // then evaluate rule conditions against FactBag. // 6. Accumulate findings; return QualifierResult. // // Cache keys: "QualifierPlan:{clientId}:{qualifierId}" // Cache TTL: 60 minutes (refreshed on activation). // ============================================================ public class QualifierFacadeImpl : IQualifierFacadeImpl { private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(60); private readonly IQualifierDAL _Dal; private readonly IServiceProvider _ServiceProvider; private readonly IMemoryCache _Cache; private readonly IQualifierCacheInvalidator _CacheInvalidator; private readonly ILogger _Logger; public QualifierFacadeImpl( IQualifierDAL Dal, IServiceProvider serviceProvider, IMemoryCache cache, IQualifierCacheInvalidator cacheInvalidator, ILogger logger) { _Dal = Dal; _ServiceProvider = serviceProvider; _Cache = cache; _CacheInvalidator = cacheInvalidator; _Logger = logger; } public async Task ExecuteStageAsync( QualifierExecutionContext ctx, LoginDTO loginDTO, CancellationToken ct = default) { // ── Step 1: Resolve active qualifier bindings ───────────────────────── var bindings = await _Dal.GetEntityBindings( ctx.EntityCode, ctx.Stage, ctx.Scope, loginDTO, ct); if (bindings.Count == 0) { _Logger.LogDebug( "QualifierFacade: no bindings for entity={EntityCode} stage={Stage} scope={Scope}", ctx.EntityCode, ctx.Stage, ctx.Scope); // Breadcrumb for Zipkin: if a later step (e.g. a workflow EnrichQualifier // assignment) needed a fact from this stage, this is the moment it went // missing — visible on the span timeline even though nothing failed yet here. GB5Trace.Step("qualifier-stage-no-bindings", new { entityCode = ctx.EntityCode, stage = ctx.Stage, scope = ctx.Scope, tenantId = loginDTO.ClientId }); return QualifierResult.Success([], ctx.Facts.ExportAll()); } // ── Step 2: Load compiled plans (L2 cache) ──────────────────────────── var plans = new Dictionary(); foreach (var binding in bindings) { var plan = await GetOrLoadPlanAsync(binding.QualifierId, loginDTO, ct); if (plan is not null) plans[binding.QualifierId] = plan; } // ── Step 3: Load dependencies, build execution order ────────────────── var allDeps = await _Dal.GetDependenciesByEntity( ctx.EntityCode, ctx.Stage, ctx.Scope, loginDTO, ct); var executionOrder = ComputeExecutionLevels( bindings.Select(b => b.QualifierId).ToList(), allDeps); // ── Step 4: Execute level by level ──────────────────────────────────── var allFindings = new List(); foreach (var level in executionOrder) { // Qualifiers in the same level run concurrently. var tasks = level .Where(qid => plans.ContainsKey(qid)) .Select(qid => ExecuteQualifierAsync(plans[qid], ctx, loginDTO, ct)); var levelResults = await Task.WhenAll(tasks); foreach (var findings in levelResults) allFindings.AddRange(findings); } // ── Step 5: Build final result ──────────────────────────────────────── var factsExport = ctx.Facts.ExportAll(); bool hasErrors = allFindings.Any(f => f.Severity == FindingSeverity.Error); _Logger.LogInformation( "QualifierFacade: stage={Stage} scope={Scope} entity={EntityCode} — " + "{QualifierCount} qualifiers, {FindingCount} findings, hasErrors={HasErrors}", ctx.Stage, ctx.Scope, ctx.EntityCode, plans.Count, allFindings.Count, hasErrors); return hasErrors ? QualifierResult.Failure(allFindings, factsExport) : QualifierResult.Success(allFindings, factsExport); } // ═════════════════════════════════════════════════════════ // PRIVATE HELPERS // ═════════════════════════════════════════════════════════ private async Task GetOrLoadPlanAsync( int qualifierId, LoginDTO loginDTO, CancellationToken ct) { var cacheKey = QualifierCacheInvalidator.PlanKey(qualifierId, loginDTO.ClientId); if (_Cache.TryGetValue(cacheKey, out QualifierPlanSnapshot? cached)) return cached; // Cache miss — load from DB. var version = await _Dal.GetActiveQualifierPlan(qualifierId, loginDTO, ct); if (version is null || string.IsNullOrWhiteSpace(version.SnapshotJson)) { _Logger.LogWarning( "QualifierFacade: no active plan for qualifier {QualifierId}", qualifierId); // This qualifier IS bound (MQUALIFIERENTITY matched) but has no Active // MQUALIFIERVERSION/SNAPSHOTJSON — it is skipped silently otherwise. // Surfaced explicitly so Zipkin shows "bound but never runs" distinctly // from "not bound at all". GB5Trace.Step("qualifier-plan-missing", new { qualifierId, tenantId = loginDTO.ClientId, reason = version is null ? "no active MQUALIFIERVERSION row" : "active version has empty SNAPSHOTJSON" }); return null; } try { var snapshot = JsonSerializer.Deserialize( version.SnapshotJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); _Cache.Set(cacheKey, snapshot, CacheTtl); _CacheInvalidator.Register(qualifierId, loginDTO.ClientId); return snapshot; } catch (Exception ex) { _Logger.LogError(ex, "QualifierFacade: failed to deserialize snapshot for qualifier {QualifierId}", qualifierId); return null; } } private async Task> ExecuteQualifierAsync( QualifierPlanSnapshot plan, QualifierExecutionContext ctx, LoginDTO loginDTO, CancellationToken ct) { var findings = new List(); // ── Run all methods in sorted order ─────────────────────────────────── foreach (var method in plan.Methods.OrderBy(m => m.SortOrder)) { var modeKey = MapExecutionMode(method.ExecutionMode); var executor = _ServiceProvider.GetKeyedService(modeKey); if (executor is null) { _Logger.LogError( "QualifierFacade: no executor for mode '{ModeKey}' (qualifier {QualifierId})", modeKey, plan.QualifierId); continue; } var methodFindings = await executor.ExecuteAsync(method, ctx, loginDTO, ct); findings.AddRange(methodFindings); } // ── Evaluate rule conditions against FactBag ────────────────────────── var conditionFindings = EvaluateConditions(plan, ctx); findings.AddRange(conditionFindings); // Stamp qualifierId on all findings from this qualifier. return findings.Select(f => new QualifierFinding { Severity = f.Severity, Code = f.Code, Message = f.Message, DocumentPath = f.DocumentPath, LineIndex = f.LineIndex, GroupKey = f.GroupKey, QualifierId = plan.QualifierId, QualifierCode = f.QualifierCode }).ToList(); } private static IReadOnlyList EvaluateConditions( QualifierPlanSnapshot plan, QualifierExecutionContext ctx) { if (plan.Conditions.Count == 0) return []; // Group conditions by rule group — evaluate each group's AND/OR operator. var groupMap = plan.RuleGroups.ToDictionary(rg => rg.RuleGroupId); var condsByGroup = plan.Conditions .GroupBy(c => c.RuleGroupId) .ToDictionary(g => g.Key, g => g.ToList()); var findings = new List(); foreach (var (groupId, conditions) in condsByGroup) { if (!groupMap.TryGetValue(groupId, out var group)) continue; var orderedConditions = conditions.OrderBy(c => c.SortOrder).ToList(); if (group.GroupOperator.Equals("OR", StringComparison.OrdinalIgnoreCase)) { // OR — the group is satisfied if ANY condition passes; only raise a // finding if EVERY condition in the group failed. A failure on an // earlier condition must not raise on its own if a later one passes. SnapshotCondition? lastFailed = null; bool anyPassed = false; foreach (var condition in orderedConditions) { if (EvaluateCondition(condition, ctx)) { anyPassed = true; break; } lastFailed = condition; } if (!anyPassed && lastFailed is not null) { findings.Add(new QualifierFinding { Severity = (FindingSeverity)lastFailed.Severity, Code = lastFailed.ErrorCode ?? $"COND_{lastFailed.ConditionId}", Message = lastFailed.ErrorMessage ?? $"Condition {lastFailed.ConditionId} failed.", DocumentPath = lastFailed.FieldPath, QualifierId = plan.QualifierId, QualifierCode = string.Empty }); } } else { // AND — short-circuit on first failure. foreach (var condition in orderedConditions) { if (EvaluateCondition(condition, ctx)) continue; findings.Add(new QualifierFinding { Severity = (FindingSeverity)condition.Severity, Code = condition.ErrorCode ?? $"COND_{condition.ConditionId}", Message = condition.ErrorMessage ?? $"Condition {condition.ConditionId} failed.", DocumentPath = condition.FieldPath, QualifierId = plan.QualifierId, QualifierCode = string.Empty }); break; } } } return findings; } private static bool EvaluateCondition( SnapshotCondition condition, QualifierExecutionContext ctx) { // Resolve field value from FactBag document facts. var exported = ctx.Facts.ExportAll(); object? fieldValue = null; if (exported.TryGetValue("Document", out var docSection) && docSection is IReadOnlyDictionary docFacts) { docFacts.TryGetValue(condition.FieldPath, out fieldValue); } string? strValue = fieldValue?.ToString(); string? expected = condition.ExpectedValue; return condition.ConditionOperator.ToUpperInvariant() switch { "NOTNULL" => fieldValue is not null, "ISNULL" => fieldValue is null, "EQ" => string.Equals(strValue, expected, StringComparison.OrdinalIgnoreCase), "NEQ" => !string.Equals(strValue, expected, StringComparison.OrdinalIgnoreCase), "GT" => CompareTo(strValue, expected) > 0, "GTE" => CompareTo(strValue, expected) >= 0, "LT" => CompareTo(strValue, expected) < 0, "LTE" => CompareTo(strValue, expected) <= 0, "REGEX" => EvaluateRegex(strValue, expected), "IN" => EvaluateIn(strValue, expected), "NOTEMPTY" => !string.IsNullOrWhiteSpace(strValue), _ => true // Unknown operator — pass (don't block on misconfiguration) }; } private static int CompareTo(string? a, string? b) { if (decimal.TryParse(a, out var da) && decimal.TryParse(b, out var db)) return da.CompareTo(db); return string.Compare(a, b, StringComparison.OrdinalIgnoreCase); } private static bool EvaluateRegex(string? value, string? pattern) { if (string.IsNullOrEmpty(pattern) || value is null) return false; try { return System.Text.RegularExpressions.Regex.IsMatch(value, pattern); } catch { return false; } } private static bool EvaluateIn(string? value, string? csvList) { if (string.IsNullOrEmpty(csvList) || value is null) return false; var items = csvList.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); return items.Any(i => string.Equals(i, value, StringComparison.OrdinalIgnoreCase)); } /// /// Groups qualifier IDs into execution levels based on the dependency DAG. /// Qualifiers with no prerequisites are in Level 1; their dependents in Level 2, etc. /// private static List> ComputeExecutionLevels( List qualifierIds, IReadOnlyList deps) { if (qualifierIds.Count == 0) return []; // Multiple MQUALIFIERENTITY bindings can legally point at the same QualifierId // (e.g. two SortOrder-distinct rows for the same entity/stage/scope) -- the // qualifier should still only be scheduled/executed once per stage pass. qualifierIds = qualifierIds.Distinct().ToList(); var inDegree = qualifierIds.ToDictionary(id => id, _ => 0); var adjacency = qualifierIds.ToDictionary(id => id, _ => new List()); foreach (var dep in deps.Where(d => qualifierIds.Contains(d.QualifierId) && qualifierIds.Contains(d.DependsOnQualifierId))) { adjacency[dep.DependsOnQualifierId].Add(dep.QualifierId); inDegree[dep.QualifierId]++; } var levels = new List>(); var queue = new Queue(inDegree.Where(kv => kv.Value == 0).Select(kv => kv.Key)); while (queue.Count > 0) { var level = new List(); int count = queue.Count; for (int i = 0; i < count; i++) { var node = queue.Dequeue(); level.Add(node); foreach (var neighbor in adjacency[node]) { inDegree[neighbor]--; if (inDegree[neighbor] == 0) queue.Enqueue(neighbor); } } levels.Add(level); } // Any remaining nodes with inDegree > 0 are in a cycle — skip them // (cycle detection was done at compile time; this is a runtime guard). return levels; } private static string MapExecutionMode(byte mode) => mode switch { 1 => "NCalc", 2 => "DataSource", 3 => "Service", _ => "NCalc" }; } }