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"
};
}
}