using System.Text;
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
///
/// Arguments for an audit tree invocation.
///
public sealed class AuditTreeArgs
{
///
/// The execution ID (GUID) to look up. May be any node in the chain — the
/// server walks to the root and returns the full tree.
///
public string ExecutionId { get; set; } = string.Empty;
}
///
/// Represents one execution node as returned by GET /api/audit/tree.
/// Property names match the server's camelCase JSON serialisation of
/// ExecutionTreeNode.
///
internal sealed class AuditTreeNodeDto
{
public Guid ExecutionId { get; init; }
public Guid? ParentExecutionId { get; init; }
public int RowCount { get; init; }
public string[] Channels { get; init; } = Array.Empty();
public string[] Statuses { get; init; } = Array.Empty();
public string? SourceSiteId { get; init; }
public string? SourceInstanceId { get; init; }
public DateTime? FirstOccurredAtUtc { get; init; }
public DateTime? LastOccurredAtUtc { get; init; }
}
///
/// Pure helpers for the audit tree subcommand: builds the query string,
/// calls GET /api/audit/tree, and renders the result as either an
/// indented ASCII tree (table format) or raw JSON. Kept separate from the
/// command wiring so each piece is unit-testable without standing up the
/// command tree.
///
public static class AuditTreeHelpers
{
private static readonly JsonSerializerOptions JsonReadOptions = new()
{
PropertyNameCaseInsensitive = true,
};
private static readonly JsonSerializerOptions JsonWriteOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
};
///
/// Builds the query string for GET /api/audit/tree.
///
/// The execution ID GUID.
/// A relative path + query string ready to append to the base URL.
public static string BuildUrl(Guid executionId)
=> $"api/audit/tree?executionId={executionId:D}";
///
/// Executes the tree lookup: GETs /api/audit/tree and renders the result
/// in the requested format. Returns the process exit code (0 = success,
/// 1 = error, 2 = authorization failure).
///
/// The management HTTP client.
/// The execution ID to look up.
/// "table" (default) or "json".
/// The output writer for results.
/// A task that resolves to the process exit code.
public static async Task RunTreeAsync(
ManagementHttpClient client,
Guid executionId,
string format,
TextWriter output)
{
var url = BuildUrl(executionId);
var response = await client.SendGetAsync(url, TimeSpan.FromSeconds(30));
if (response.JsonData == null)
{
OutputFormatter.WriteError(
response.Error ?? "Audit tree request failed.", response.ErrorCode ?? "ERROR");
return CommandHelpers.IsAuthorizationFailure(response) ? 2 : 1;
}
var nodes = ParseNodes(response.JsonData);
if (format == "json")
{
WriteJson(nodes, output);
}
else
{
WriteTable(nodes, executionId, output);
}
output.Flush();
return 0;
}
///
/// Parses the JSON array from the server into an array of
/// .
///
/// The raw JSON response body.
/// An array of deserialized tree nodes (empty on parse failure).
internal static AuditTreeNodeDto[] ParseNodes(string json)
{
try
{
return JsonSerializer.Deserialize(json, JsonReadOptions)
?? Array.Empty();
}
catch (JsonException)
{
return Array.Empty();
}
}
///
/// Renders the nodes as pretty-printed JSON to .
///
internal static void WriteJson(AuditTreeNodeDto[] nodes, TextWriter output)
{
output.WriteLine(JsonSerializer.Serialize(nodes, JsonWriteOptions));
}
///
/// Renders the nodes as an indented ASCII tree. The root node (null
/// ParentExecutionId) is printed first; each child is indented
/// two spaces per depth level. The queried/entry-point node is marked
/// with [*].
///
internal static void WriteTable(
AuditTreeNodeDto[] nodes,
Guid queriedExecutionId,
TextWriter output)
{
if (nodes.Length == 0)
{
output.WriteLine("(no execution tree found)");
return;
}
// Build a parent → children lookup (keyed by non-null parent Guid).
// Nodes whose ParentExecutionId is null are roots and are not placed in
// the lookup; they are identified separately below.
var childrenOf = new Dictionary>();
foreach (var node in nodes)
{
if (node.ParentExecutionId is { } parentId)
{
if (!childrenOf.ContainsKey(parentId))
childrenOf[parentId] = new List();
childrenOf[parentId].Add(node);
}
}
// Identify roots: nodes whose ParentExecutionId is null, or whose parent
// is not present in the node set (stub-root case).
var nodeIds = new HashSet(nodes.Select(n => n.ExecutionId));
var roots = nodes
.Where(n => n.ParentExecutionId == null || !nodeIds.Contains(n.ParentExecutionId.Value))
.ToList();
// Render depth-first.
var sb = new StringBuilder();
foreach (var root in roots)
{
RenderNode(root, depth: 0, childrenOf, queriedExecutionId, sb);
}
output.Write(sb.ToString());
}
private static void RenderNode(
AuditTreeNodeDto node,
int depth,
Dictionary> childrenOf,
Guid queriedExecutionId,
StringBuilder sb)
{
var indent = new string(' ', depth * 2);
var marker = node.ExecutionId == queriedExecutionId ? " [*]" : string.Empty;
var channels = node.Channels.Length > 0 ? string.Join(",", node.Channels) : "-";
var statuses = node.Statuses.Length > 0 ? string.Join(",", node.Statuses) : "-";
var site = node.SourceSiteId ?? "-";
var instance = node.SourceInstanceId ?? "-";
var first = node.FirstOccurredAtUtc.HasValue
? node.FirstOccurredAtUtc.Value.ToString("yyyy-MM-ddTHH:mm:ssZ")
: "-";
sb.AppendLine(
$"{indent}{node.ExecutionId:D}{marker} rows={node.RowCount} channels=[{channels}] statuses=[{statuses}] site={site} instance={instance} first={first}");
if (childrenOf.TryGetValue(node.ExecutionId, out var children))
{
foreach (var child in children)
{
RenderNode(child, depth + 1, childrenOf, queriedExecutionId, sb);
}
}
}
}