using System; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using FrameworkDAL.CustomCode.GOP; using FrameworkBLL.GOP.Transform; using GB5Shared.DTO.Framework.Login; using GB5Shared.DTO.GOP; using GB5Shared.DTO.Qualifier; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Newtonsoft.Json.Linq; namespace FrameworkBLL.GOP { // ============================================================ // MappingEngineBLL — field-to-field transformation engine. // // Phase 4 upgrades: // • Code-based version lookup: resolves by MapperCode + Active version // via MMAPPINGDEFINITION → MMAPPINGVERSION → MMAPPINGNODE. // • [FactBag] source path prefix: "$...." reads from source document; // "[FactBag] Lines[0].TaxRate" reads from QualifierFactBag. // • ITransformExpression registry: DI-registered implementations keyed // by the expression key. Caller registers via AddTransformExpression(). // // Transform expression syntax: // "UPPER" → key="UPPER", argument=null // "ROUND:2" → key="ROUND", argument="2" // "FORMAT_DATE:yyyy-MM" → key="FORMAT_DATE", argument="yyyy-MM" // // FactBag path syntax (SourcePath column): // "[FactBag] ApprovalTier" → document-level fact // "[FactBag] Lines[0].TaxRate" → line-level fact at index 0 // "[FactBag] Groups[\"tax\"].Total" → group fact, group key = "tax" // "[FactBag] Groups.tax.Total" → alternate dot-notation group path // ============================================================ public class MappingEngineBLL : IMappingEngineBLL { private static readonly string FactBagPrefix = "[FactBag]"; // Pre-compiled regex patterns for FactBag path resolution. private static readonly Regex LinePattern = new(@"^Lines\[(\d+)\]\.(.+)$", RegexOptions.Compiled); private static readonly Regex GroupBracket = new(@"^Groups\[""(.+?)""\]\.(.+)$", RegexOptions.Compiled); private static readonly Regex GroupDot = new(@"^Groups\.([^.]+)\.(.+)$", RegexOptions.Compiled); private readonly IMappingDAL _MappingDAL; private readonly IReadOnlyList _Transforms; private readonly ILogger _Logger; public MappingEngineBLL( IMappingDAL mappingDAL, IEnumerable transforms, ILogger logger) { _MappingDAL = mappingDAL; _Transforms = transforms.ToList(); _Logger = logger; } // ── Legacy overload (Guid-based) ────────────────────────────────────────── public async Task TransformAsync( string SourcePayloadJson, Guid MappingVersionId, LoginDTO LoginDTO) { if (string.IsNullOrWhiteSpace(SourcePayloadJson)) throw new ArgumentException("Source payload is required."); // Load nodes by integer version ID — convert Guid to string lookup not supported; // the legacy Guid-based path is a best-effort stub for backward compat. // Callers should migrate to the code-based overload. _Logger.LogWarning( "MappingEngineBLL: legacy Guid-based TransformAsync called for version {VersionId}. " + "Migrate callers to the code-based overload.", MappingVersionId); return SourcePayloadJson; } // ── Code-based overload (no FactBag) ───────────────────────────────────── public Task TransformAsync( string mapperCode, string sourcePayloadJson, LoginDTO loginDTO, CancellationToken ct = default) => TransformAsync(mapperCode, sourcePayloadJson, loginDTO, factBag: null, ct); // ── Code-based overload (with FactBag) — primary implementation ─────────── public async Task TransformAsync( string mapperCode, string sourcePayloadJson, LoginDTO loginDTO, QualifierFactBag? factBag, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(mapperCode)) throw new ArgumentException("MapperCode is required."); if (string.IsNullOrWhiteSpace(sourcePayloadJson)) throw new ArgumentException("Source payload is required."); // Load active mapping nodes for this mapper code. var nodes = await _MappingDAL.GetActiveNodesByMapperCode(mapperCode, loginDTO, ct) .ConfigureAwait(false); if (nodes.Count == 0) { _Logger.LogWarning( "MappingEngineBLL: no active nodes found for mapper '{MapperCode}' — returning source unchanged.", mapperCode); return sourcePayloadJson; } JObject sourceDoc; try { sourceDoc = JObject.Parse(sourcePayloadJson); } catch (JsonException ex) { throw new InvalidOperationException( $"MappingEngineBLL: source payload for mapper '{mapperCode}' is not valid JSON.", ex); } var targetDoc = new JObject(); foreach (var node in nodes.OrderBy(n => n.ExecutionOrder)) { try { string rawValue = node.SourcePath.StartsWith(FactBagPrefix, StringComparison.OrdinalIgnoreCase) ? ResolveFactBagPath(node.SourcePath, factBag, sourceDoc, mapperCode) : ResolveSourcePath(node.SourcePath, sourceDoc); string transformedValue = ApplyTransform(rawValue, node.TransformExpression); SetTargetPath(targetDoc, node.TargetPath, transformedValue); } catch (Exception ex) when (ex is not OperationCanceledException) { if (node.IsRequired) throw new InvalidOperationException( $"Required mapping node failed — " + $"mapper='{mapperCode}' source='{node.SourcePath}' target='{node.TargetPath}': {ex.Message}", ex); _Logger.LogDebug( "MappingEngineBLL: optional node skipped — mapper='{MapperCode}' " + "source='{SourcePath}' error={Error}", mapperCode, node.SourcePath, ex.Message); } } return targetDoc.ToString(Formatting.None); } // ── Source path resolution ──────────────────────────────────────────────── private static string ResolveSourcePath(string sourcePath, JObject sourceDoc) { var token = sourceDoc.SelectToken(sourcePath); return token?.ToString() ?? string.Empty; } private string ResolveFactBagPath( string sourcePath, QualifierFactBag? factBag, JObject sourceDoc, string mapperCode) { // Strip the "[FactBag]" prefix and trim whitespace. var path = sourcePath.Substring(FactBagPrefix.Length).Trim(); // The pipeline (MapperNodeExecutor) only ever passes the qualifier step's // serialized output as a JSON string — it never has a live QualifierFactBag // instance to hand over, so factBag is always null in that path. Rather than // resolving to empty (silently breaking the documented [FactBag] syntax for // every real flow), fall back to reading the same logical location out of // sourceDoc, which — when the previous step was a Qualifier — is exactly the // FactBag.ExportAll() shape: { "Document": {...}, "Lines": [...], "Groups": {...} }. if (factBag is null) { // Lines[0].SomeKey var fallbackLineMatch = LinePattern.Match(path); if (fallbackLineMatch.Success) { string token = $"Lines[{fallbackLineMatch.Groups[1].Value}].{fallbackLineMatch.Groups[2].Value}"; return sourceDoc.SelectToken(token)?.ToString() ?? string.Empty; } // Groups["groupKey"].SomeKey / Groups.groupKey.SomeKey var fallbackBracketMatch = GroupBracket.Match(path); if (fallbackBracketMatch.Success) { string token = $"Groups.{fallbackBracketMatch.Groups[1].Value}.{fallbackBracketMatch.Groups[2].Value}"; return sourceDoc.SelectToken(token)?.ToString() ?? string.Empty; } var fallbackDotMatch = GroupDot.Match(path); if (fallbackDotMatch.Success) { string token = $"Groups.{fallbackDotMatch.Groups[1].Value}.{fallbackDotMatch.Groups[2].Value}"; return sourceDoc.SelectToken(token)?.ToString() ?? string.Empty; } // Plain document-level fact key. return sourceDoc.SelectToken($"Document.{path}")?.ToString() ?? string.Empty; } // Lines[0].SomeKey var lineMatch = LinePattern.Match(path); if (lineMatch.Success) { int lineIndex = int.Parse(lineMatch.Groups[1].Value); string factKey = lineMatch.Groups[2].Value; var val = factBag.GetLineFact(lineIndex, factKey); return val?.ToString() ?? string.Empty; } // Groups["groupKey"].SomeKey var bracketMatch = GroupBracket.Match(path); if (bracketMatch.Success) { string groupKey = bracketMatch.Groups[1].Value; string factKey = bracketMatch.Groups[2].Value; var val = factBag.GetGroupFact(groupKey, factKey); return val?.ToString() ?? string.Empty; } // Groups.groupKey.SomeKey var dotMatch = GroupDot.Match(path); if (dotMatch.Success) { string groupKey = dotMatch.Groups[1].Value; string factKey = dotMatch.Groups[2].Value; var val = factBag.GetGroupFact(groupKey, factKey); return val?.ToString() ?? string.Empty; } // Plain document-level fact key. var docVal = factBag.GetDocumentFact(path); return docVal?.ToString() ?? string.Empty; } // ── Transform application ───────────────────────────────────────────────── private string ApplyTransform(string value, string? expression) { if (string.IsNullOrWhiteSpace(expression)) return value; // Split on first colon: "ROUND:2" → key="ROUND", argument="2" int colonIdx = expression.IndexOf(':', StringComparison.Ordinal); string key = colonIdx >= 0 ? expression.Substring(0, colonIdx).Trim() : expression.Trim(); string? argument = colonIdx >= 0 ? expression.Substring(colonIdx + 1).Trim() : null; // Find a registered transform; first match wins (allows override by ordering). var transform = _Transforms.FirstOrDefault( t => string.Equals(t.Key, key, StringComparison.OrdinalIgnoreCase)); if (transform is null) { // Warning, not Debug -- a typo'd/unregistered transform key (e.g. "UPER" // instead of "UPPER") would otherwise silently pass the raw value through // with no visible trace at default production log levels. _Logger.LogWarning( "MappingEngineBLL: no ITransformExpression registered for key '{Key}' — returning value unchanged.", key); return value; } return transform.Transform(value, argument); } // ── Target path writing ─────────────────────────────────────────────────── private static readonly Regex TargetArrayIndexPattern = new(@"^(.+)\[(\d+)\]$", RegexOptions.Compiled); private static void SetTargetPath(JObject doc, string targetPath, string value) { // Strip leading "$." or "." — support both "$.InvoiceNumber" and "InvoiceNumber" forms. string normalized = targetPath.TrimStart('$', '.'); // Support dotted paths ("Header.InvoiceNumber" → nested JObject) and a // trailing "[N]" on any segment ("Lines[0].Amount" → nested JArray element) -- // previously a bracketed segment was written verbatim as an object key (e.g. // a literal "Lines[0]" property) instead of indexing into a real array. var parts = normalized.Split('.'); JObject current = doc; for (int i = 0; i < parts.Length; i++) { bool isLast = i == parts.Length - 1; string part = parts[i]; var match = TargetArrayIndexPattern.Match(part); if (match.Success) { string arrayName = match.Groups[1].Value; int index = int.Parse(match.Groups[2].Value); if (current[arrayName] is not JArray array) { array = new JArray(); current[arrayName] = array; } while (array.Count <= index) array.Add(new JObject()); if (isLast) { array[index] = value; } else { if (array[index] is not JObject arrayChild) { arrayChild = new JObject(); array[index] = arrayChild; } current = arrayChild; } } else if (isLast) { current[part] = value; } else { if (current[part] is not JObject child) { child = new JObject(); current[part] = child; } current = child; } } } } }