using System.Globalization;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
/// A node of a predicted flow (Flow Debugger design 2026-09-05 §5.5 — the "Predicted" state).
/// Stable id within the flow (n1, n2, …).
/// The step kind this node would produce when run.
/// The swimlane it is drawn in.
/// Display title — the route target, script name, attribute name, …
/// True when the target is computed from inputs (not a string literal) and could not be predicted statically.
/// Advisory text — an early-return guard that precedes this node, a depth cap, a parse error.
public sealed record PredictedNode(string Id, FlowStepKind Kind, FlowLane Lane, string Title, bool InputDependent, string? Note);
/// A directed edge between two predicted nodes.
/// Source node id.
/// Target node id.
/// True when exactly one end sits in the Central lane (a central↔site hop).
public sealed record PredictedEdge(string From, string To, bool CrossesBoundary);
/// The predicted shape of a flow: nodes in source order plus the chain edges between them.
public sealed record PredictedFlow(IReadOnlyList Nodes, IReadOnlyList Edges)
{
/// An empty prediction.
public static readonly PredictedFlow Empty = new([], []);
}
///
/// Predicts the shape of an inbound-API or site-script flow from script syntax alone
/// (Flow Debugger design 2026-09-05 §5.5): which routed calls, script calls, attribute writes
/// and attribute waits the script would produce, in source order, before anything runs.
///
///
/// This is a syntax walk, not a semantic one — it matches the same member-access chains the
/// script API exposes (Route.To(x).Call(y), Scripts.Call(y),
/// ….Attributes.WriteBatchAndWaitAsync(…), ….Attributes[x] = v, …) by text with
/// whitespace stripped, exactly as 's hardening walker does.
/// String-literal arguments resolve to titles; anything else is flagged
/// . Branching is not modelled: every recognised call
/// is emitted once in source order, and an if (…) return …; guard that precedes the next
/// node is surfaced as a note on that node. The predicted node kinds are exactly the kinds a
/// traced run records, so the page can overlay the real graph on the prediction by kind + title.
///
///
///
/// Kept DB-free on purpose: a caller supplies resolveRouteScript(instanceCode, scriptName)
/// to turn a route target into a site script body (Central UI resolves instance → template →
/// script); this class only decides when to ask.
///
///
public static class FlowShapePredictor
{
/// Default recursion cap — an inbound method's routed site script is depth 1, a nested Scripts.Call depth 2.
public const int DefaultMaxDepth = 4;
/// Note attached to the request node when the script does not parse.
public const string ParseErrorNote = "parse error";
///
/// Predicts the flow of an inbound API method: an
/// node first, the method script's calls, one hop into each routed site script the resolver
/// can supply (recursively up to ), and a
/// node last.
///
/// The inbound method name (titles the request node).
/// The method's C# script body.
///
/// (instanceCode, scriptName) → the site script body, or null when it cannot be
/// resolved. Called with the literal instance code when the route target is a constant, and
/// with the target expression's source text otherwise (so a resolver may still answer for a
/// known expression, and a test resolver may answer for anything).
///
/// Recursion cap; nested script bodies beyond it are not expanded.
public static PredictedFlow PredictInboundMethod(
string methodName,
string methodScript,
Func resolveRouteScript,
int maxDepth = DefaultMaxDepth)
{
ArgumentNullException.ThrowIfNull(resolveRouteScript);
var b = new Builder(resolveRouteScript, Math.Max(1, maxDepth));
var request = b.AddNode(FlowStepKind.InboundRequest, FlowLane.Central, $"POST /api/{methodName}", inputDependent: false, note: null);
try
{
if (!b.Walk(methodScript ?? string.Empty, FlowLane.Central, instanceCode: null, depth: 0))
b.Annotate(request, ParseErrorNote);
}
catch (Exception)
{
// A predictor must never take the page down; degrade to the request/response pair.
b.Reset(keep: 1);
b.Annotate(request, ParseErrorNote);
}
b.AddNode(FlowStepKind.Response, FlowLane.Central, "Response", inputDependent: false, note: null);
return b.Build();
}
///
/// Predicts the flow of a site script run: a node first,
/// then the script's attribute writes/waits and nested calls. Nested Scripts.Call
/// targets are not expanded (no resolver) — they appear as unexpanded ScriptRun nodes.
///
/// The script name (titles the root node).
/// The script's C# body.
/// Recursion cap.
public static PredictedFlow PredictSiteScript(string scriptName, string scriptBody, int maxDepth = DefaultMaxDepth)
{
var b = new Builder((_, _) => null, Math.Max(1, maxDepth));
var root = b.AddNode(FlowStepKind.ScriptRun, FlowLane.Site, scriptName, inputDependent: false, note: null);
try
{
if (!b.Walk(scriptBody ?? string.Empty, FlowLane.Site, instanceCode: null, depth: 1))
b.Annotate(root, ParseErrorNote);
}
catch (Exception)
{
b.Reset(keep: 1);
b.Annotate(root, ParseErrorNote);
}
return b.Build();
}
/// Accumulates nodes/edges and owns the chain ("previous node") state across nested walks.
private sealed class Builder
{
private readonly Func _resolve;
private readonly int _maxDepth;
private readonly List _nodes = [];
private readonly List _edges = [];
private string? _previous;
private string? _pendingNote;
internal Builder(Func resolve, int maxDepth)
{
_resolve = resolve;
_maxDepth = maxDepth;
}
internal PredictedFlow Build() => new(_nodes.ToArray(), _edges.ToArray());
/// Adds a node, chains it to the previous one, and consumes any pending early-return note.
internal string AddNode(FlowStepKind kind, FlowLane lane, string title, bool inputDependent, string? note)
{
var id = "n" + (_nodes.Count + 1).ToString(CultureInfo.InvariantCulture);
var mergedNote = note ?? _pendingNote;
if (note != null && _pendingNote != null)
mergedNote = $"{_pendingNote}; {note}";
_pendingNote = null;
_nodes.Add(new PredictedNode(id, kind, lane, title, inputDependent, mergedNote));
if (_previous != null)
{
var fromLane = _nodes.First(n => n.Id == _previous).Lane;
_edges.Add(new PredictedEdge(_previous, id, (fromLane == FlowLane.Central) != (lane == FlowLane.Central)));
}
_previous = id;
return id;
}
internal void Annotate(string id, string note)
{
var i = _nodes.FindIndex(n => n.Id == id);
if (i < 0) return;
var n = _nodes[i];
_nodes[i] = n with { Note = n.Note == null ? note : $"{n.Note}; {note}" };
}
/// Drops everything after the first nodes (fault recovery).
internal void Reset(int keep)
{
if (_nodes.Count > keep) _nodes.RemoveRange(keep, _nodes.Count - keep);
var ids = _nodes.Select(n => n.Id).ToHashSet(StringComparer.Ordinal);
_edges.RemoveAll(e => !ids.Contains(e.From) || !ids.Contains(e.To));
_previous = _nodes.Count > 0 ? _nodes[^1].Id : null;
_pendingNote = null;
}
///
/// Walks one script body in the given lane. Returns false when the body has parse
/// errors (nothing is emitted for it), true otherwise.
///
internal bool Walk(string code, FlowLane lane, string? instanceCode, int depth)
{
var tree = CSharpSyntaxTree.ParseText(code, new CSharpParseOptions(kind: SourceCodeKind.Script));
if (tree.GetDiagnostics().Any(d => d.Severity == DiagnosticSeverity.Error))
return false;
new Walker(this, lane, instanceCode, depth).Visit(tree.GetRoot());
_pendingNote = null; // a trailing guard with nothing after it has no node to annotate
return true;
}
internal void QueueNote(string note)
=> _pendingNote = _pendingNote == null ? note : $"{_pendingNote}; {note}";
/// Expands a routed / nested script call one level, if the resolver can supply its body.
internal void ExpandScript(string instanceCode, string scriptName, int depth)
{
if (depth >= _maxDepth)
{
Annotate(_previous!, "depth limit reached; not expanded");
return;
}
string? body;
try
{
body = _resolve(instanceCode, scriptName);
}
catch (Exception)
{
body = null;
}
if (body == null)
return;
if (!Walk(body, FlowLane.Site, instanceCode, depth + 1))
Annotate(_previous!, ParseErrorNote);
}
}
/// Source-order visitor that turns recognised script-API calls into nodes.
private sealed class Walker : CSharpSyntaxWalker
{
private readonly Builder _b;
private readonly FlowLane _lane;
private readonly string? _instanceCode;
private readonly int _depth;
internal Walker(Builder b, FlowLane lane, string? instanceCode, int depth)
{
_b = b;
_lane = lane;
_instanceCode = instanceCode;
_depth = depth;
}
private static string Strip(string text) => new(text.Where(c => !char.IsWhiteSpace(c)).ToArray());
///
/// An if (…) { … return …; } guard becomes a note on the node that FOLLOWS it.
/// The condition is visited first (it may itself contain a traced call — the Delmia
/// site script's if (!await …WriteBatchAndWaitAsync(…)) return …), then the
/// body, and only then is the note queued for whatever comes next.
///
public override void VisitIfStatement(IfStatementSyntax node)
{
Visit(node.Condition);
Visit(node.Statement);
if (node.Else != null)
Visit(node.Else);
var returnsEarly = node.Statement.DescendantNodesAndSelf().OfType().Any()
|| node.Statement.DescendantNodesAndSelf().OfType().Any();
if (returnsEarly)
_b.QueueNote($"early return: {Condense(node.Condition.ToString())}");
}
public override void VisitAssignmentExpression(AssignmentExpressionSyntax node)
{
// recv.Attributes["X"] = v / Attributes["X"] = v
if (node.Left is ElementAccessExpressionSyntax ea
&& Strip(ea.Expression.ToString()) is var recv
&& (recv == "Attributes" || recv.EndsWith(".Attributes", StringComparison.Ordinal)))
{
var (attr, dependent) = LiteralOrExpr(ea.ArgumentList.Arguments.FirstOrDefault()?.Expression);
_b.AddNode(FlowStepKind.AttributeWrite, FlowLane.Tags, Qualify(attr), dependent, null);
}
base.VisitAssignmentExpression(node);
}
public override void VisitInvocationExpression(InvocationExpressionSyntax node)
{
// Visit inner invocations first? No — source order for a chained call like
// Route.To(x).Call(y) is the outer call; the inner To(x) is part of its target.
// Nested invocations inside ARGUMENTS are visited after this node is emitted,
// which is the order they would produce trace rows only when they are awaited
// first — acceptable for a shape preview.
var handled = TryHandle(node);
if (!handled)
base.VisitInvocationExpression(node);
else
foreach (var arg in node.ArgumentList.Arguments)
Visit(arg.Expression);
}
private bool TryHandle(InvocationExpressionSyntax node)
{
if (node.Expression is not MemberAccessExpressionSyntax ma)
return false;
var member = ma.Name.Identifier.ValueText;
var receiverText = Strip(ma.Expression.ToString());
var args = node.ArgumentList.Arguments;
// Route.To().Call(