using System.Text.Json;
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
///
/// Mutable, all-nullable form model for the driver resilience override. Binds the typed fields in
/// DriverResilienceSection; null/blank = "use the driver's tier default", so a blank form serializes
/// back to null (preserving DriverInstance.ResilienceConfig = null). Emits / reads the exact override
/// JSON shape DriverResilienceOptionsParser consumes.
///
/// Round-trip is non-lossy (04/C-7): the stored JSON is kept as a bag,
/// and overlays only the known fields onto that bag — so unknown top-level keys,
/// unknown capability entries, and unknown per-policy fields all survive a load→save, mirroring the
/// driver tag editors' preserve-unknown discipline (TagConfigJson). A stored blob that could not
/// be parsed sets and is never overwritten —
/// returns the original text verbatim.
///
///
public sealed class ResilienceFormModel
{
public static readonly string[] Capabilities =
["Read", "Write", "Discover", "Subscribe", "Probe", "AlarmSubscribe", "AlarmAcknowledge", "HistoryRead"];
/// Gets or sets the driver recycle-interval override, in seconds; null = use the tier default.
public int? RecycleIntervalSeconds { get; set; }
// capability name -> (timeout, retry, breaker), each nullable.
/// Gets or sets the per-capability resilience overrides, keyed by capability name.
public Dictionary Policies { get; set; } =
Capabilities.ToDictionary(c => c, _ => new CapabilityRow(), StringComparer.OrdinalIgnoreCase);
///
/// True when the stored ResilienceConfig could not be parsed as JSON. The section locks editing and
/// returns unchanged so an unrelated keystroke can
/// never silently overwrite an unreadable column.
///
public bool ParseFailed { get; private set; }
/// The original stored text when — returned verbatim by .
public string? RawStoredJson { get; private set; }
private JsonObject _bag = new();
/// Per-capability timeout/retry/breaker override row; null fields fall back to the tier default.
public sealed class CapabilityRow
{
/// Gets or sets the timeout override, in seconds; null = use the tier default.
public int? TimeoutSeconds { get; set; }
/// Gets or sets the retry-count override; null = use the tier default.
public int? RetryCount { get; set; }
/// Gets or sets the circuit-breaker failure-threshold override; null = use the tier default.
public int? BreakerFailureThreshold { get; set; }
/// Gets a value indicating whether all fields in this row are unset.
public bool IsEmpty => TimeoutSeconds is null && RetryCount is null && BreakerFailureThreshold is null;
}
///
/// Range-validate the per-capability overrides against and return
/// one human-readable message per violation (naming capability + field + legal range). This is
/// authoring-time feedback, not the enforcement layer — the parser's clamp is the authoritative
/// guard, so the form still emits. Mirrors the driver tag editors' Validate() idiom (01/S-6).
///
/// A list of range-violation messages; empty when every override is in range or blank.
public IReadOnlyList Validate()
{
var msgs = new List();
foreach (var (cap, row) in Policies)
{
if (row.TimeoutSeconds is { } t &&
(t < ResiliencePolicyRanges.MinTimeoutSeconds || t > ResiliencePolicyRanges.MaxTimeoutSeconds))
msgs.Add($"{cap} timeout must be between {ResiliencePolicyRanges.MinTimeoutSeconds} and " +
$"{ResiliencePolicyRanges.MaxTimeoutSeconds} seconds (got {t}).");
if (row.RetryCount is { } r &&
(r < ResiliencePolicyRanges.MinRetryCount || r > ResiliencePolicyRanges.MaxRetryCount))
msgs.Add($"{cap} retries must be between {ResiliencePolicyRanges.MinRetryCount} and " +
$"{ResiliencePolicyRanges.MaxRetryCount} (got {r}).");
if (row.BreakerFailureThreshold is { } b &&
(b < ResiliencePolicyRanges.MinBreakerFailureThreshold || b == 1 ||
b > ResiliencePolicyRanges.MaxBreakerFailureThreshold))
msgs.Add($"{cap} breaker threshold must be 0 (off) or between " +
$"{ResiliencePolicyRanges.MinEnabledBreakerFailureThreshold} and " +
$"{ResiliencePolicyRanges.MaxBreakerFailureThreshold} (got {b}).");
}
return msgs;
}
///
/// Parse the override JSON into a form model, preserving every key in an internal bag. Malformed JSON
/// sets + and yields an otherwise-empty model.
///
/// The raw resilience-override JSON, or null/blank if there is no override.
/// A populated form model.
public static ResilienceFormModel FromJson(string? json)
{
var model = new ResilienceFormModel();
if (string.IsNullOrWhiteSpace(json)) return model;
JsonObject bag;
try
{
bag = JsonNode.Parse(json) as JsonObject ?? new JsonObject();
}
catch (JsonException)
{
model.ParseFailed = true;
model.RawStoredJson = json;
return model;
}
model._bag = bag;
model.RecycleIntervalSeconds = GetIntOrNull(bag, "recycleIntervalSeconds");
if (bag.TryGetPropertyValue("capabilityPolicies", out var cpNode) && cpNode is JsonObject cp)
{
foreach (var (capName, polNode) in cp)
{
// Known capability names populate rows (reading only the known per-policy fields — unknown
// per-policy fields stay untouched in the bag). Unknown capability entries stay in the bag.
if (polNode is JsonObject pol && model.Policies.TryGetValue(capName, out var row))
{
row.TimeoutSeconds = GetIntOrNull(pol, "timeoutSeconds");
row.RetryCount = GetIntOrNull(pol, "retryCount");
row.BreakerFailureThreshold = GetIntOrNull(pol, "breakerFailureThreshold");
}
}
}
return model;
}
///
/// Overlay the model's non-null known values onto the preserved bag and serialize. Returns null when
/// the result is empty (blank form = tier defaults). When , returns the
/// original unparseable text unchanged.
///
/// The serialized override JSON, the original text when parse failed, or null when nothing is set.
public string? ToJson()
{
if (ParseFailed) return RawStoredJson;
SetOrRemoveInt(_bag, "recycleIntervalSeconds", RecycleIntervalSeconds);
var cp = _bag.TryGetPropertyValue("capabilityPolicies", out var cpNode) && cpNode is JsonObject existing
? existing
: null;
foreach (var (cap, row) in Policies)
{
// Find this known capability's existing policy object (case-insensitive), else its canonical key.
var polKey = cap;
JsonObject? pol = null;
if (cp is not null)
{
foreach (var (k, v) in cp)
{
if (string.Equals(k, cap, StringComparison.OrdinalIgnoreCase) && v is JsonObject vo)
{
polKey = k;
pol = vo;
break;
}
}
}
if (row.IsEmpty)
{
// Clear the known fields; drop the policy entry only if no unknown fields remain.
if (pol is not null)
{
pol.Remove("timeoutSeconds");
pol.Remove("retryCount");
pol.Remove("breakerFailureThreshold");
if (pol.Count == 0) cp!.Remove(polKey);
}
}
else
{
if (cp is null)
{
cp = new JsonObject();
_bag["capabilityPolicies"] = cp;
}
if (pol is null)
{
pol = new JsonObject();
cp[polKey] = pol;
}
SetOrRemoveInt(pol, "timeoutSeconds", row.TimeoutSeconds);
SetOrRemoveInt(pol, "retryCount", row.RetryCount);
SetOrRemoveInt(pol, "breakerFailureThreshold", row.BreakerFailureThreshold);
}
}
if (cp is not null && cp.Count == 0) _bag.Remove("capabilityPolicies");
return _bag.Count == 0 ? null : _bag.ToJsonString();
}
private static int? GetIntOrNull(JsonObject o, string name)
=> o.TryGetPropertyValue(name, out var n) && n is JsonValue v && v.TryGetValue(out var i)
? i
: null;
private static void SetOrRemoveInt(JsonObject o, string name, int? value)
{
if (value is null) o.Remove(name);
else o[name] = JsonValue.Create(value.Value);
}
}