using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace ScadaLink.CLI.Commands;
///
/// Filter arguments for an audit query invocation. Mirrors the Bundle B
/// GET /api/audit/query filter parameters; /
/// are time-specs (relative like 1h/7d, or absolute ISO-8601).
///
public sealed class AuditQueryArgs
{
public string? Since { get; set; }
public string? Until { get; set; }
public string? Channel { get; set; }
public string? Kind { get; set; }
public string? Status { get; set; }
public string? Site { get; set; }
public string? Target { get; set; }
public string? Actor { get; set; }
public string? CorrelationId { get; set; }
public bool ErrorsOnly { get; set; }
public int PageSize { get; set; } = 100;
}
///
/// Pure helpers for the audit query subcommand: time-spec resolution, query-string
/// construction, and the keyset-cursor paging loop. Kept separate from the command wiring
/// so each piece is unit-testable without standing up the command tree.
///
public static class AuditQueryHelpers
{
// where unit is s/m/h/d — a relative offset back from "now".
private static readonly Regex RelativeSpec = new(@"^(\d+)([smhd])$", RegexOptions.Compiled);
///
/// Resolves a time-spec to an absolute . Accepts a
/// relative offset (30s, 15m, 1h, 7d) interpreted as
/// minus the offset, or an absolute ISO-8601 timestamp.
///
/// The spec is neither a known relative form nor a parseable ISO-8601 timestamp.
public static DateTimeOffset ResolveTimeSpec(string spec, DateTimeOffset now)
{
if (string.IsNullOrWhiteSpace(spec))
throw new FormatException("Empty time value.");
var match = RelativeSpec.Match(spec.Trim());
if (match.Success)
{
var amount = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
var offset = match.Groups[2].Value switch
{
"s" => TimeSpan.FromSeconds(amount),
"m" => TimeSpan.FromMinutes(amount),
"h" => TimeSpan.FromHours(amount),
"d" => TimeSpan.FromDays(amount),
_ => throw new FormatException($"Unknown time unit in '{spec}'."),
};
return now - offset;
}
if (DateTimeOffset.TryParse(spec, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var absolute))
{
return absolute;
}
throw new FormatException(
$"Invalid time value '{spec}'. Use a relative offset (e.g. 1h, 24h, 7d) or an ISO-8601 timestamp.");
}
///
/// Builds the ?... query string for GET /api/audit/query from the filter
/// args plus an optional keyset cursor. Unset filters are omitted. --errors-only
/// maps to status=Failed (the server takes a single status value).
///
public static string BuildQueryString(
AuditQueryArgs args, DateTimeOffset now, DateTimeOffset? afterOccurredAtUtc, string? afterEventId)
{
var parts = new List();
void Add(string key, string? value)
{
if (!string.IsNullOrWhiteSpace(value))
parts.Add($"{key}={Uri.EscapeDataString(value)}");
}
if (!string.IsNullOrWhiteSpace(args.Since))
Add("fromUtc", ResolveTimeSpec(args.Since!, now).ToString("o", CultureInfo.InvariantCulture));
if (!string.IsNullOrWhiteSpace(args.Until))
Add("toUtc", ResolveTimeSpec(args.Until!, now).ToString("o", CultureInfo.InvariantCulture));
Add("channel", args.Channel);
Add("kind", args.Kind);
// --errors-only is a convenience shorthand for the single-value Failed status
// filter. The server's status filter accepts one value, so --errors-only and an
// explicit --status are mutually exclusive in effect; --errors-only wins.
Add("status", args.ErrorsOnly ? "Failed" : args.Status);
Add("sourceSiteId", args.Site);
Add("target", args.Target);
Add("actor", args.Actor);
Add("correlationId", args.CorrelationId);
Add("pageSize", args.PageSize.ToString(CultureInfo.InvariantCulture));
if (afterOccurredAtUtc.HasValue)
Add("afterOccurredAtUtc", afterOccurredAtUtc.Value.ToString("o", CultureInfo.InvariantCulture));
Add("afterEventId", afterEventId);
return parts.Count == 0 ? string.Empty : "?" + string.Join("&", parts);
}
///
/// Executes the query: GETs /api/audit/query, renders each page with
/// , and — when is set —
/// follows nextCursor until the server returns a null cursor. Returns the
/// process exit code (0 success, non-zero on HTTP/transport error).
///
public static async Task RunQueryAsync(
ManagementHttpClient client,
AuditQueryArgs args,
bool fetchAll,
IAuditFormatter formatter,
TextWriter output,
DateTimeOffset now)
{
DateTimeOffset? afterOccurredAtUtc = null;
string? afterEventId = null;
while (true)
{
var qs = BuildQueryString(args, now, afterOccurredAtUtc, afterEventId);
var response = await client.SendGetAsync("api/audit/query" + qs, TimeSpan.FromSeconds(30));
if (response.JsonData == null)
{
OutputFormatter.WriteError(
response.Error ?? "Audit query failed.", response.ErrorCode ?? "ERROR");
return 1;
}
using var doc = JsonDocument.Parse(response.JsonData);
var root = doc.RootElement;
var events = root.TryGetProperty("events", out var evts) && evts.ValueKind == JsonValueKind.Array
? evts.EnumerateArray().ToList()
: new List();
formatter.WritePage(events, output);
output.Flush();
if (!fetchAll)
return 0;
if (!root.TryGetProperty("nextCursor", out var cursor)
|| cursor.ValueKind != JsonValueKind.Object)
{
return 0;
}
afterOccurredAtUtc = cursor.TryGetProperty("afterOccurredAtUtc", out var c1)
&& c1.ValueKind == JsonValueKind.String
? DateTimeOffset.Parse(c1.GetString()!, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal)
: null;
afterEventId = cursor.TryGetProperty("afterEventId", out var c2)
&& c2.ValueKind == JsonValueKind.String
? c2.GetString()
: null;
// A malformed cursor (object present but missing both keys) would loop
// forever — treat it as the end of results.
if (afterOccurredAtUtc == null && afterEventId == null)
return 0;
}
}
}