using Akka.Actor; using Microsoft.Extensions.Logging; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Messages.Instance; using ScadaLink.Commons.Messages.ScriptExecution; using ScadaLink.Commons.Types; namespace ScadaLink.SiteRuntime.Scripts; /// /// WP-18: Script Runtime API — injected into Script/Alarm Execution Actors. /// Provides the API surface that user scripts interact with: /// Instance.GetAttribute("name") /// Instance.SetAttribute("name", value) /// Instance.CallScript("scriptName", params) /// Scripts.CallShared("scriptName", params) /// /// WP-13 (Phase 7): Integration surface APIs: /// ExternalSystem.Call("systemName", "methodName", params) /// ExternalSystem.CachedCall("systemName", "methodName", params) /// Database.Connection("name") /// Database.CachedWrite("name", "sql", params) /// Notify.To("listName").Send("subject", "message") /// /// WP-20: Recursion Limit — call depth tracked and enforced. /// public class ScriptRuntimeContext { private readonly IActorRef _instanceActor; private readonly IActorRef _self; private readonly SharedScriptLibrary _sharedScriptLibrary; private readonly int _currentCallDepth; private readonly int _maxCallDepth; private readonly TimeSpan _askTimeout; private readonly ILogger _logger; private readonly string _instanceName; /// /// WP-13: External system client for ExternalSystem.Call/CachedCall. /// private readonly IExternalSystemClient? _externalSystemClient; /// /// WP-13: Database gateway for Database.Connection/CachedWrite. /// private readonly IDatabaseGateway? _databaseGateway; /// /// WP-13: Notification delivery for Notify.To().Send(). /// private readonly INotificationDeliveryService? _notificationService; public ScriptRuntimeContext( IActorRef instanceActor, IActorRef self, SharedScriptLibrary sharedScriptLibrary, int currentCallDepth, int maxCallDepth, TimeSpan askTimeout, string instanceName, ILogger logger, IExternalSystemClient? externalSystemClient = null, IDatabaseGateway? databaseGateway = null, INotificationDeliveryService? notificationService = null) { _instanceActor = instanceActor; _self = self; _sharedScriptLibrary = sharedScriptLibrary; _currentCallDepth = currentCallDepth; _maxCallDepth = maxCallDepth; _askTimeout = askTimeout; _instanceName = instanceName; _logger = logger; _externalSystemClient = externalSystemClient; _databaseGateway = databaseGateway; _notificationService = notificationService; } /// /// Gets the current value of an attribute from the Instance Actor. /// Uses Ask pattern (system boundary between script execution and instance state). /// public async Task GetAttribute(string attributeName) { var correlationId = Guid.NewGuid().ToString(); var request = new GetAttributeRequest( correlationId, _instanceName, attributeName, DateTimeOffset.UtcNow); var response = await _instanceActor.Ask(request, _askTimeout); if (!response.Found) { _logger.LogWarning( "GetAttribute: attribute '{Attribute}' not found on instance '{Instance}'", attributeName, _instanceName); } return response.Value; } /// /// Sets an attribute value. For data-connected attributes the Instance Actor /// forwards the write to the DCL, which writes the physical device; the /// in-memory value is not optimistically updated. For static attributes the /// Instance Actor updates the in-memory value and persists the override to /// SQLite. All mutations are serialized through the Instance Actor mailbox. /// /// The write is awaited so that a device-write failure on a data-connected /// attribute is surfaced synchronously to the calling script as an /// . /// public async Task SetAttribute(string attributeName, string value) { var correlationId = Guid.NewGuid().ToString(); var command = new SetStaticAttributeCommand( correlationId, _instanceName, attributeName, value, DateTimeOffset.UtcNow); // Ask — mutation serialized through the Instance Actor mailbox; the reply // carries the device-write outcome for data-connected attributes. var response = await _instanceActor.Ask(command, _askTimeout); if (!response.Success) { throw new InvalidOperationException( $"SetAttribute('{attributeName}') failed: {response.ErrorMessage}"); } } /// /// Calls a sibling script on the same instance by name (Ask pattern). /// WP-20: Enforces recursion limit. /// WP-22: Uses Ask pattern for CallScript. /// may be a dictionary or an anonymous object /// (new { name = "Bob" }) — see . /// public async Task CallScript(string scriptName, object? parameters = null) { var nextDepth = _currentCallDepth + 1; if (nextDepth > _maxCallDepth) { var msg = $"Script call depth exceeded maximum of {_maxCallDepth}. " + $"CallScript('{scriptName}') rejected at depth {nextDepth}."; _logger.LogError(msg); throw new InvalidOperationException(msg); } var correlationId = Guid.NewGuid().ToString(); var request = new ScriptCallRequest( scriptName, ScriptArgs.Normalize(parameters), nextDepth, correlationId); // Ask the Instance Actor, which routes to the appropriate Script Actor var result = await _instanceActor.Ask(request, _askTimeout); if (!result.Success) { throw new InvalidOperationException( $"CallScript('{scriptName}') failed: {result.ErrorMessage}"); } return result.ReturnValue; } /// /// Provides access to shared script execution via the Scripts property. /// public ScriptCallHelper Scripts => new(_sharedScriptLibrary, this, _currentCallDepth, _maxCallDepth, _logger); /// /// WP-13: Provides access to external system calls. /// ExternalSystem.Call("systemName", "methodName", params) /// ExternalSystem.CachedCall("systemName", "methodName", params) /// public ExternalSystemHelper ExternalSystem => new(_externalSystemClient, _instanceName, _logger); /// /// WP-13: Provides access to database operations. /// Database.Connection("name") /// Database.CachedWrite("name", "sql", params) /// public DatabaseHelper Database => new(_databaseGateway, _instanceName, _logger); /// /// WP-13: Provides access to notification delivery. /// Notify.To("listName").Send("subject", "message") /// public NotifyHelper Notify => new(_notificationService, _instanceName, _logger); /// /// Helper class for Scripts.CallShared() syntax. /// public class ScriptCallHelper { private readonly SharedScriptLibrary _library; private readonly ScriptRuntimeContext _context; private readonly int _currentCallDepth; private readonly int _maxCallDepth; private readonly ILogger _logger; internal ScriptCallHelper( SharedScriptLibrary library, ScriptRuntimeContext context, int currentCallDepth, int maxCallDepth, ILogger logger) { _library = library; _context = context; _currentCallDepth = currentCallDepth; _maxCallDepth = maxCallDepth; _logger = logger; } /// /// WP-17: Executes a shared script inline (direct method call, not actor message). /// WP-20: Enforces recursion limit. /// may be a dictionary or an anonymous /// object (new { name = "Bob" }) — see . /// public async Task CallShared( string scriptName, object? parameters = null, CancellationToken cancellationToken = default) { var nextDepth = _currentCallDepth + 1; if (nextDepth > _maxCallDepth) { var msg = $"Script call depth exceeded maximum of {_maxCallDepth}. " + $"CallShared('{scriptName}') rejected at depth {nextDepth}."; _logger.LogError(msg); throw new InvalidOperationException(msg); } return await _library.ExecuteAsync( scriptName, _context, ScriptArgs.Normalize(parameters), cancellationToken); } } /// /// WP-13: Helper for ExternalSystem.Call/CachedCall syntax. /// public class ExternalSystemHelper { private readonly IExternalSystemClient? _client; private readonly string _instanceName; private readonly ILogger _logger; internal ExternalSystemHelper(IExternalSystemClient? client, string instanceName, ILogger logger) { _client = client; _instanceName = instanceName; _logger = logger; } public async Task Call( string systemName, string methodName, IReadOnlyDictionary? parameters = null, CancellationToken cancellationToken = default) { if (_client == null) throw new InvalidOperationException("External system client not available"); return await _client.CallAsync(systemName, methodName, parameters, cancellationToken); } public async Task CachedCall( string systemName, string methodName, IReadOnlyDictionary? parameters = null, CancellationToken cancellationToken = default) { if (_client == null) throw new InvalidOperationException("External system client not available"); return await _client.CachedCallAsync(systemName, methodName, parameters, _instanceName, cancellationToken); } } /// /// WP-13: Helper for Database.Connection/CachedWrite syntax. /// public class DatabaseHelper { private readonly IDatabaseGateway? _gateway; private readonly string _instanceName; private readonly ILogger _logger; internal DatabaseHelper(IDatabaseGateway? gateway, string instanceName, ILogger logger) { _gateway = gateway; _instanceName = instanceName; _logger = logger; } public async Task Connection( string name, CancellationToken cancellationToken = default) { if (_gateway == null) throw new InvalidOperationException("Database gateway not available"); return await _gateway.GetConnectionAsync(name, cancellationToken); } public async Task CachedWrite( string name, string sql, IReadOnlyDictionary? parameters = null, CancellationToken cancellationToken = default) { if (_gateway == null) throw new InvalidOperationException("Database gateway not available"); await _gateway.CachedWriteAsync(name, sql, parameters, _instanceName, cancellationToken); } } /// /// WP-13: Helper for Notify.To("listName").Send("subject", "message") syntax. /// public class NotifyHelper { private readonly INotificationDeliveryService? _service; private readonly string _instanceName; private readonly ILogger _logger; internal NotifyHelper(INotificationDeliveryService? service, string instanceName, ILogger logger) { _service = service; _instanceName = instanceName; _logger = logger; } public NotifyTarget To(string listName) { return new NotifyTarget(listName, _service, _instanceName, _logger); } } /// /// WP-13: Target for Notify.To("listName").Send("subject", "message"). /// public class NotifyTarget { private readonly string _listName; private readonly INotificationDeliveryService? _service; private readonly string _instanceName; private readonly ILogger _logger; internal NotifyTarget(string listName, INotificationDeliveryService? service, string instanceName, ILogger logger) { _listName = listName; _service = service; _instanceName = instanceName; _logger = logger; } public async Task Send( string subject, string message, CancellationToken cancellationToken = default) { if (_service == null) throw new InvalidOperationException("Notification service not available"); return await _service.SendAsync(_listName, subject, message, _instanceName, cancellationToken); } } }