using System.Diagnostics;
using System.Text.Json;
using Akka.Actor;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
///
/// C3/N3: a routed call failed because the target site never answered — the routed
/// Ask expired. Unreachability is typed at the Ask boundary from
/// (the communication layer's own signal: an
/// unreachable/contactless site has its enveloped message dropped so the Ask times
/// out caller-side) — it is never inferred from an error-message substring.
/// Derives from so existing callers that
/// catch the base type are unaffected, while
/// catches this more specific type to surface the spec's SITE_UNREACHABLE
/// error code instead of a generic SCRIPT_ERROR. A Success=false
/// response means the site answered (reachable by definition) and stays a
/// plain .
///
public sealed class SiteUnreachableException : InvalidOperationException
{
/// Initializes a new with the given message.
/// The routing-level failure message that indicated the site was unreachable.
public SiteUnreachableException(string message) : base(message) { }
/// Initializes a new wrapping the underlying Ask-timeout.
/// The routing-level failure message.
/// The underlying from the routed Ask.
public SiteUnreachableException(string message, Exception innerException) : base(message, innerException) { }
}
///
/// Arch-review F2: a routed call whose target site is outside the caller's site
/// scope. Raised by RouteTarget.ResolveSiteAsync — the single choke point every routed
/// operation passes through — so it is thrown before any site traffic: nothing is sent,
/// nothing is read, nothing is written.
///
///
/// The scope exists for the Flow Debugger's method-entry Live run, where an operator picks a
/// method rather than a site: a site-scoped Deployer must not reach outside their sites just
/// because the method's script routes there. The ordinary HTTP inbound path attaches no scope
/// (an API key is not a site-scoped principal) and is unaffected.
///
///
///
/// Derives from so existing callers catching the base
/// type are unaffected, while catches this more specific
/// type to surface the SITE_SCOPE_DENIED error code instead of a generic
/// SCRIPT_ERROR. Deliberately NOT the ManagementService's SiteScopeViolationException
/// — InboundAPI cannot reference ManagementService; the runner converts the code back.
///
///
public sealed class RouteSiteScopeException : InvalidOperationException
{
/// Initializes a new with the given message.
/// The scope-denial message naming the instance and its site.
public RouteSiteScopeException(string message) : base(message) { }
}
///
/// Route.To() helper for cross-site calls from inbound API scripts.
/// Resolves instance to site, routes via , blocks until
/// response or timeout. Site unreachable returns error (no store-and-forward).
///
/// The helper carries the executing method's
/// (the method-level timeout). Routed calls inherit that deadline by default, so a
/// natural script — Route.To("inst").Call("doWork", p) — is timeout-bounded
/// without the script having to thread a token explicitly.
///
public class RouteHelper
{
private readonly IInstanceLocator _instanceLocator;
private readonly IInstanceRouter _instanceRouter;
private readonly CancellationToken _deadlineToken;
private readonly CancellationToken _requestAbortedToken;
private readonly Guid? _parentExecutionId;
private readonly ICentralAuditWriter? _auditWriter;
private readonly INodeIdentityProvider? _nodeIdentity;
private readonly bool _trace;
private readonly Action? _traceSink;
private readonly IReadOnlySet? _permittedSiteIdentifiers;
private readonly bool _captureBodies;
private readonly ILogger? _logger;
///
/// Initializes a new with no deadline token and no parent execution id.
/// DI (AddScoped<RouteHelper>()) resolves the two optional audit collaborators when the
/// host registers them; a bare new RouteHelper(locator, router) writes no audit rows.
///
/// Service to resolve the site id for a given instance code.
/// Service to route cross-site calls to the resolved site.
/// Flow Debugger (design 2026-09-05 §5.1): central-direct-write audit writer for the always-on RouteCall row; null = no row.
/// Stamps SourceNode (central-a/central-b) on the RouteCall row; null = NULL.
/// Arch-review F8: logs a Warning when the best-effort RouteCall audit write faults; null = silent.
public RouteHelper(
IInstanceLocator instanceLocator,
IInstanceRouter instanceRouter,
ICentralAuditWriter? auditWriter = null,
INodeIdentityProvider? nodeIdentity = null,
ILogger? logger = null)
: this(instanceLocator, instanceRouter, CancellationToken.None, CancellationToken.None, parentExecutionId: null,
auditWriter, nodeIdentity, trace: false, traceSink: null, permittedSiteIdentifiers: null, captureBodies: true, logger)
{
}
private RouteHelper(
IInstanceLocator instanceLocator,
IInstanceRouter instanceRouter,
CancellationToken deadlineToken,
CancellationToken requestAbortedToken,
Guid? parentExecutionId,
ICentralAuditWriter? auditWriter,
INodeIdentityProvider? nodeIdentity,
bool trace,
Action? traceSink,
IReadOnlySet? permittedSiteIdentifiers,
bool captureBodies,
ILogger? logger)
{
_permittedSiteIdentifiers = permittedSiteIdentifiers;
_instanceLocator = instanceLocator;
_instanceRouter = instanceRouter;
_deadlineToken = deadlineToken;
_requestAbortedToken = requestAbortedToken;
_parentExecutionId = parentExecutionId;
_auditWriter = auditWriter;
_nodeIdentity = nodeIdentity;
_trace = trace;
_traceSink = traceSink;
_captureBodies = captureBodies;
_logger = logger;
}
///
/// Returns a whose routed calls inherit
/// (the executing method's timeout) by default.
/// calls this when it builds the script
/// context so the method timeout actually covers routed calls, as the design doc
/// requires.
///
/// The executing method's timeout cancellation token to inherit for routed calls.
/// A new inheriting the given deadline token.
public RouteHelper WithDeadline(CancellationToken deadlineToken) =>
new(_instanceLocator, _instanceRouter, deadlineToken, _requestAbortedToken, _parentExecutionId,
_auditWriter, _nodeIdentity, _trace, _traceSink, _permittedSiteIdentifiers, _captureBodies, _logger);
///
/// Returns a carrying the raw request-abort
/// token (a client disconnect) separately from the method deadline. Most
/// routed calls remain bounded by the method deadline (which already incorporates the
/// abort), but uses this token so its wait
/// is bounded by the WAIT timeout — not the generic method deadline (spec §6) — while
/// still being cancelled by a client disconnect.
///
/// The raw client-disconnect token, independent of the method timeout.
/// A new carrying the given request-abort token.
public RouteHelper WithRequestAborted(CancellationToken requestAbortedToken) =>
new(_instanceLocator, _instanceRouter, _deadlineToken, requestAbortedToken, _parentExecutionId,
_auditWriter, _nodeIdentity, _trace, _traceSink, _permittedSiteIdentifiers, _captureBodies, _logger);
///
/// Audit Log (ParentExecutionId): returns a whose
/// routed requests carry
/// as .
/// For an inbound API request this is the inbound request's own per-request
/// execution id, so the routed site script records the inbound request as its
/// parent. calls this when it builds the
/// script context.
///
/// The inbound request's execution id to stamp as the spawning parent on routed calls, or null for non-routed runs.
/// A new carrying the given parent execution id.
public RouteHelper WithParentExecutionId(Guid? parentExecutionId) =>
new(_instanceLocator, _instanceRouter, _deadlineToken, _requestAbortedToken, parentExecutionId,
_auditWriter, _nodeIdentity, _trace, _traceSink, _permittedSiteIdentifiers, _captureBodies, _logger);
///
/// Flow Debugger (design 2026-09-05 §5.2): returns a whose routed
/// calls and writes carry Trace = , so the site traces the
/// spawned run. The always-on RouteCall row is written regardless of this flag.
///
/// True to trace the routed site runs.
/// A new carrying the flag.
public RouteHelper WithTrace(bool trace) =>
new(_instanceLocator, _instanceRouter, _deadlineToken, _requestAbortedToken, _parentExecutionId,
_auditWriter, _nodeIdentity, trace, _traceSink, _permittedSiteIdentifiers, _captureBodies, _logger);
///
/// Flow Debugger (design 2026-09-05 §7.1): returns a that also
/// hands every RouteCall row AND every inline site trace event on the routed reply
/// to , so a Live run can render without a round-trip to the
/// audit store. Null clears the sink.
///
/// Receives the audit events in emission order; null = none.
/// A new carrying the sink.
public RouteHelper WithTraceSink(Action? traceSink) =>
new(_instanceLocator, _instanceRouter, _deadlineToken, _requestAbortedToken, _parentExecutionId,
_auditWriter, _nodeIdentity, _trace, traceSink, _permittedSiteIdentifiers, _captureBodies, _logger);
///
/// Flow Debugger outbound calls (plan 2026-09-06, D5/D7): the trace sink this helper carries,
/// so can hand the SAME delegate to the script's
/// and a Live run's central Database.* rows land
/// in the same collector as its RouteCall rows. Null when no sink was attached.
///
public Action? TraceSink => _traceSink;
///
/// Arch-review F2: returns a that refuses a routed call
/// whose target site is not in — site
/// identifiers (e.g. site-a), the values returns,
/// NOT the numeric ids an AuthenticatedUser.PermittedSiteIds carries. Matching is
/// whatever comparer the supplied set uses, so pass an ordinal-ignore-case set.
///
///
/// null = unscoped, the default and the state of every ordinary inbound HTTP request.
/// An EMPTY set is not the same thing: it permits nothing, which is the correct outcome for
/// a site-scoped user whose sites no longer exist.
///
///
///
/// The Flow Debugger's method-entry Live run sets this from the operator's site-scoped
/// Deployer role, because the operator picks a method — the site is only known once
/// the script routes, so the check cannot happen up front the way it does for a value-change
/// run's named instance.
///
///
/// The site identifiers routed calls may reach; null = unscoped.
/// A new carrying the scope.
public RouteHelper WithSiteScope(IReadOnlySet? permittedSiteIdentifiers) =>
new(_instanceLocator, _instanceRouter, _deadlineToken, _requestAbortedToken, _parentExecutionId,
_auditWriter, _nodeIdentity, _trace, _traceSink, permittedSiteIdentifiers, _captureBodies, _logger);
///
/// Arch-review F11: returns a whose always-on RouteCall row
/// carries no request/response summary when is false.
/// sets this from the executing method's
/// AuditLogOptions.PerTargetOverrides[method.Name].SkipBodyCapture — the same override
/// AuditWriteMiddleware honours for the inbound row — so an operator who suppressed a
/// method's body from the audit log doesn't get it back via the routed call's arguments
/// (which are derived from that same body). Default true (unchanged behaviour).
///
/// False to omit the request/response summary on the RouteCall row.
/// A new carrying the flag.
public RouteHelper WithBodyCapture(bool captureBodies) =>
new(_instanceLocator, _instanceRouter, _deadlineToken, _requestAbortedToken, _parentExecutionId,
_auditWriter, _nodeIdentity, _trace, _traceSink, _permittedSiteIdentifiers, captureBodies, _logger);
///
/// Creates a route target for the specified instance.
///
/// The unique code of the instance to route calls to.
/// A bound to the specified instance.
public RouteTarget To(string instanceCode)
{
return new RouteTarget(
instanceCode, _instanceLocator, _instanceRouter, _deadlineToken, _requestAbortedToken, _parentExecutionId,
_auditWriter, _nodeIdentity, _trace, _traceSink, _permittedSiteIdentifiers, _captureBodies, _logger);
}
}
///
/// Represents a route target (an instance) for cross-site calls.
///
public class RouteTarget
{
private readonly string _instanceCode;
private readonly IInstanceLocator _instanceLocator;
private readonly IInstanceRouter _instanceRouter;
private readonly CancellationToken _deadlineToken;
private readonly CancellationToken _requestAbortedToken;
private readonly Guid? _parentExecutionId;
private readonly ICentralAuditWriter? _auditWriter;
private readonly INodeIdentityProvider? _nodeIdentity;
private readonly bool _trace;
private readonly Action? _traceSink;
private readonly IReadOnlySet? _permittedSiteIdentifiers;
private readonly bool _captureBodies;
private readonly ILogger? _logger;
///
/// Initializes a new for the specified instance.
///
/// The unique code of the target instance.
/// Service to resolve the site id for the instance.
/// Service to route cross-site calls.
/// Cancellation token representing the method-level deadline.
/// Raw client-disconnect token, independent of the method timeout.
/// Optional parent execution id for audit correlation on routed calls.
/// Central-direct-write audit writer for the always-on RouteCall row; null = no row.
/// Stamps SourceNode on the RouteCall row; null = NULL.
/// True to trace the routed site run.
/// Receives the emitted audit events in order; null = none.
/// Arch-review F2: site identifiers this call may reach; null = unscoped.
/// Arch-review F11: false omits the request/response summary on the RouteCall row.
/// Arch-review F8: logs a Warning when the best-effort RouteCall audit write faults; null = silent.
internal RouteTarget(
string instanceCode,
IInstanceLocator instanceLocator,
IInstanceRouter instanceRouter,
CancellationToken deadlineToken,
CancellationToken requestAbortedToken,
Guid? parentExecutionId,
ICentralAuditWriter? auditWriter = null,
INodeIdentityProvider? nodeIdentity = null,
bool trace = false,
Action? traceSink = null,
IReadOnlySet? permittedSiteIdentifiers = null,
bool captureBodies = true,
ILogger? logger = null)
{
_permittedSiteIdentifiers = permittedSiteIdentifiers;
_instanceCode = instanceCode;
_instanceLocator = instanceLocator;
_instanceRouter = instanceRouter;
_deadlineToken = deadlineToken;
_requestAbortedToken = requestAbortedToken;
_parentExecutionId = parentExecutionId;
_auditWriter = auditWriter;
_nodeIdentity = nodeIdentity;
_trace = trace;
_traceSink = traceSink;
_captureBodies = captureBodies;
_logger = logger;
}
///
/// Calls a script on the remote instance. Synchronous from API caller's
/// perspective. may be a dictionary or an
/// anonymous object (new { name = "Bob" }) — see .
///
/// When is not supplied the
/// routed call inherits the executing method's timeout, so the call is bounded by
/// the method-level deadline with no token argument.
///
/// Name of the script to call on the remote instance.
/// Optional parameters passed to the script; may be a dictionary or anonymous object.
/// Optional cancellation token; defaults to the method deadline when not supplied.
/// A task that resolves to the remote script's return value, or null.
public async Task