using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
///
/// Compact table projection for the flow-debug commands.
///
///
/// The handler returns a FlowDebugRunResult — { rootExecutionId, completed,
/// error, errorCode, events: [ … ], truncated, … } whose events are canonical
/// audit events (each carrying its domain fields inside a detailsJson string). Handed
/// to the generic renderer unprojected, the object takes the Property/Value branch and the
/// whole event array lands in one cell as raw JSON. This projects the events through the same
/// the Flow Debugger page uses and prints one row per STEP —
/// occurred | kind | lane | target | durationMs | status | lifecycle | correlation | error | logs —
/// in the order the page draws them, so a terminal user can read the swimlane top-to-bottom. An
/// outbound operation's rows (a cached call's Submit → Forwarded → Attempted → Resolve, a
/// notification's Send → Deliver) fold into ONE row whose lifecycle column counts them
/// and whose status is the latest row's (outbound-calls plan 2026-09-06, D3). logs
/// counts the step's attached ScriptLog lines (step-logs design 2026-09-09); passing
/// showLogs additionally prints those lines, and a failed step's exception detail, as
/// raw indented lines immediately under that step's row — 's table
/// renderer special-cases a single-property { "note": … } row to print it that way
/// instead of padding it into a table cell. A trailing note row (the same special case)
/// reports a failed / incomplete run (with the error code verbatim, e.g. STANDBY_NODE)
/// or a truncated event list — and, separately, an inline log-line cap (logsTruncated).
///
///
/// JSON output is never touched — --format json gets the full raw result.
///
internal static class FlowDebugTableProjection
{
///
/// Projects a FlowDebugRunResult body to the row array the table renderer
/// consumes. Returns the input unchanged when it is not JSON or not that shape, so the
/// renderer's own fallbacks still apply.
///
/// The raw success JSON body from the management API.
///
/// When true, prints each step's ScriptLog lines and (on a failed step) the first
/// three lines of its exception detail as additional indented note rows immediately
/// following that step's row. The logs
/// column (a count of attached log lines) is always present, independent of this flag.
///
/// Compact JSON suitable for table rendering.
internal static string ProjectEvents(string json, bool showLogs = false)
{
JsonDocument doc;
try
{
doc = JsonDocument.Parse(json);
}
catch (JsonException)
{
return json;
}
using (doc)
{
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object
|| !TryGetPropertyCI(root, "events", out var events)
|| events.ValueKind != JsonValueKind.Array)
{
return json;
}
var truncated = TryGetPropertyCI(root, "truncated", out var t) && t.ValueKind == JsonValueKind.True;
// Step logs (design 2026-09-09 D7): the inline copy sheds low-priority ScriptLog rows
// BEFORE it sheds step rows, so `logsTruncated` is a separate signal from `truncated`
// and needs its own note — the audit rows are complete, so a replay shows every line.
var logsTruncated = TryGetPropertyCI(root, "logsTruncated", out var lt)
&& lt.ValueKind == JsonValueKind.True;
var rows = ProjectSteps(events, truncated, showLogs);
if (rows == null)
{
// The event array did not deserialize as canonical audit events (a future wire
// shape, or a proxy body) — fall back to one row per raw event so nothing is hidden.
rows = new JsonArray();
foreach (var evt in events.EnumerateArray())
rows.Add(ProjectEvent(evt));
}
var completed = TryGetPropertyCI(root, "completed", out var c) && c.ValueKind == JsonValueKind.True;
var errorCode = StrValue(root, "errorCode");
var error = StrValue(root, "error");
var notes = new List();
if (!completed || errorCode != null || error != null)
{
notes.Add(errorCode != null
? $"{errorCode}: {error ?? "run did not complete"}"
: error ?? "run did not complete");
}
if (truncated)
notes.Add("event list truncated — the audit rows remain complete (audit tree --execution-id )");
// The page shows this; the CLI silently dropped it.
if (logsTruncated)
notes.Add("inline log lines capped — replay pulls the rest");
if (notes.Count > 0)
rows.Add(new JsonObject { ["note"] = JsonValue.Create(string.Join("; ", notes)) });
return rows.ToJsonString();
}
}
private static readonly JsonSerializerOptions EventOptions = new(JsonSerializerDefaults.Web)
{
Converters = { new JsonStringEnumConverter() },
};
///
/// Builds the flow graph from the canonical events and projects one row per step. Returns
/// null when the array is not a list of canonical audit events.
///
private static JsonArray? ProjectSteps(JsonElement events, bool truncated, bool showLogs)
{
List? list;
try
{
list = JsonSerializer.Deserialize>(events.GetRawText(), EventOptions);
}
catch (JsonException)
{
return null;
}
if (list == null || list.Any(e => e == null || e.EventId == Guid.Empty))
return null;
// Status per event: the graph's outcome is a coarser projection; the table keeps the
// audit status word (Delivered / Submitted / Parked …) the operator sees elsewhere.
var statusById = new Dictionary();
foreach (var e in list)
statusById[e.EventId] = AuditDetailsCodec.Deserialize(e.DetailsJson).Status;
var graph = FlowGraphBuilder.Build(list, truncated);
var rows = new JsonArray();
foreach (var step in graph.Steps)
{
var lifecycle = step.Lifecycle;
var status = lifecycle is { Count: > 0 }
? lifecycle[^1].Status
: statusById.TryGetValue(step.EventId, out var st) ? st : step.Outcome.ToString();
var target = step.Title;
if (step.Lane != FlowLane.External && step.Instance != null && !target.StartsWith(step.Instance, StringComparison.Ordinal))
target = $"{step.Instance}.{target}";
rows.Add(new JsonObject
{
["occurred"] = step.OccurredAtUtc.ToUniversalTime().ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture),
["kind"] = step.Kind.ToString(),
["lane"] = step.Lane.ToString(),
["target"] = target,
["durationMs"] = step.DurationMs,
["status"] = status,
["lifecycle"] = lifecycle is { Count: > 0 } ? $"{lifecycle.Count} {(lifecycle.Count == 1 ? "row" : "rows")}" : null,
["correlation"] = step.CorrelationId?.ToString("D"),
["error"] = step.ErrorMessage,
["logs"] = step.Logs?.Count ?? 0,
});
if (!showLogs)
continue;
if (step.Logs is { Count: > 0 } logs)
{
foreach (var line in logs)
rows.Add(new JsonObject { ["note"] = JsonValue.Create(FormatLogLine(line)) });
}
if (!string.IsNullOrWhiteSpace(step.ErrorDetail))
{
foreach (var line in FormatErrorDetailLines(step.ErrorDetail))
rows.Add(new JsonObject { ["note"] = JsonValue.Create(line) });
}
}
return rows;
}
/// Formats one as HH:mm:ss.fff LEVEL source message (UTC, level upper-cased and padded to 5 chars).
private static string FormatLogLine(FlowLogLine line)
{
var time = line.OccurredAtUtc.ToUniversalTime().ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture);
var level = (line.Level ?? "info").ToUpperInvariant().PadRight(5);
return $" {time} {level} {line.Source} {line.Message}";
}
///
/// Formats the first three lines of an exception's ToString() detail — the first
/// prefixed exception: , the rest indented four spaces.
///
private static IEnumerable FormatErrorDetailLines(string errorDetail)
{
var lines = errorDetail.Replace("\r\n", "\n").Split('\n');
for (var i = 0; i < Math.Min(3, lines.Length); i++)
yield return i == 0 ? $" exception: {lines[i]}" : $" {lines[i]}";
}
/// Projects one canonical audit event (camelCase JSON) to its compact row (fallback path).
private static JsonNode ProjectEvent(JsonElement evt)
{
if (evt.ValueKind != JsonValueKind.Object)
return JsonValue.Create(evt.ToString())!;
var details = AuditDetailsCodec.Deserialize(StrValue(evt, "detailsJson"));
var kind = FlowStepProjector.ResolveKind(details.Channel, details.Kind);
var lane = FlowStepProjector.ResolveLane(kind, details.SourceSiteId);
string? occurred = null;
if (TryGetPropertyCI(evt, "occurredAtUtc", out var o) && o.ValueKind == JsonValueKind.String
&& o.TryGetDateTimeOffset(out var when))
{
occurred = when.ToUniversalTime().ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture);
}
var target = StrValue(evt, "target");
var instance = details.SourceInstanceId;
var shownTarget = target ?? details.SourceScript;
if (instance != null && shownTarget != null && !shownTarget.StartsWith(instance, StringComparison.Ordinal))
shownTarget = $"{instance}.{shownTarget}";
return new JsonObject
{
["occurred"] = occurred,
["kind"] = kind.ToString(),
["lane"] = lane.ToString(),
["target"] = shownTarget ?? instance,
["durationMs"] = details.DurationMs,
["status"] = details.Status,
["error"] = details.ErrorMessage,
};
}
private static bool TryGetPropertyCI(JsonElement obj, string name, out JsonElement value)
{
foreach (var prop in obj.EnumerateObject())
{
if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase))
{
value = prop.Value;
return true;
}
}
value = default;
return false;
}
private static string? StrValue(JsonElement obj, string name)
=> TryGetPropertyCI(obj, name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
}