using System.Diagnostics;
using System.Text.Json;
using System.Text.RegularExpressions;
using Akka.Actor;
using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Messages.Instance;
using ScadaLink.Commons.Messages.Notification;
using ScadaLink.Commons.Messages.ScriptExecution;
using ScadaLink.Commons.Types;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.StoreAndForward;

namespace ScadaLink.SiteRuntime.Scripts;

/// <summary>
/// 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.
/// </summary>
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;

    /// <summary>
    /// WP-13: External system client for ExternalSystem.Call/CachedCall.
    /// </summary>
    private readonly IExternalSystemClient? _externalSystemClient;

    /// <summary>
    /// WP-13: Database gateway for Database.Connection/CachedWrite.
    /// </summary>
    private readonly IDatabaseGateway? _databaseGateway;

    /// <summary>
    /// Notification Outbox: the site Store-and-Forward Engine that <c>Notify.Send</c>
    /// enqueues notifications into. The S&amp;F engine forwards them to central.
    /// </summary>
    private readonly StoreAndForwardService? _storeAndForward;

    /// <summary>
    /// Notification Outbox: the site communication actor that <c>Notify.Status</c>
    /// queries central through (via the ClusterClient command/control transport).
    /// </summary>
    private readonly ICanTell? _siteCommunicationActor;

    /// <summary>
    /// Notification Outbox: this site's identifier, stamped on enqueued notifications.
    /// </summary>
    private readonly string _siteId;

    /// <summary>
    /// Notification Outbox (FU3): identifier of the script currently executing in this
    /// context — stamped onto <c>NotificationSubmit.SourceScript</c> for the central
    /// audit trail. Uses the Site Event Logging "Source" convention
    /// (<c>"ScriptActor:&lt;scriptName&gt;"</c>). Null when no single script owns the
    /// context (e.g. alarm on-trigger paths that do not wire the Notify outbox).
    /// </summary>
    private readonly string? _sourceScript;

    /// <summary>
    /// Audit Log #23: best-effort emitter for boundary-crossing actions executed
    /// by the script. Optional — when null the helpers degrade to a no-op audit
    /// path so tests / contexts that do not need the audit pipeline still work.
    /// </summary>
    private readonly IAuditWriter? _auditWriter;

    public ScriptRuntimeContext(
        IActorRef instanceActor,
        IActorRef self,
        SharedScriptLibrary sharedScriptLibrary,
        int currentCallDepth,
        int maxCallDepth,
        TimeSpan askTimeout,
        string instanceName,
        ILogger logger,
        IExternalSystemClient? externalSystemClient = null,
        IDatabaseGateway? databaseGateway = null,
        StoreAndForwardService? storeAndForward = null,
        ICanTell? siteCommunicationActor = null,
        string siteId = "",
        string? sourceScript = null,
        IAuditWriter? auditWriter = null)
    {
        _instanceActor = instanceActor;
        _self = self;
        _sharedScriptLibrary = sharedScriptLibrary;
        _currentCallDepth = currentCallDepth;
        _maxCallDepth = maxCallDepth;
        _askTimeout = askTimeout;
        _instanceName = instanceName;
        _logger = logger;
        _externalSystemClient = externalSystemClient;
        _databaseGateway = databaseGateway;
        _storeAndForward = storeAndForward;
        _siteCommunicationActor = siteCommunicationActor;
        _siteId = siteId;
        _sourceScript = sourceScript;
        _auditWriter = auditWriter;
    }

    /// <summary>
    /// Gets the current value of an attribute from the Instance Actor.
    /// Uses Ask pattern (system boundary between script execution and instance state).
    /// </summary>
    public async Task<object?> GetAttribute(string attributeName)
    {
        var correlationId = Guid.NewGuid().ToString();
        var request = new GetAttributeRequest(
            correlationId, _instanceName, attributeName, DateTimeOffset.UtcNow);

        var response = await _instanceActor.Ask<GetAttributeResponse>(request, _askTimeout);

        if (!response.Found)
        {
            _logger.LogWarning(
                "GetAttribute: attribute '{Attribute}' not found on instance '{Instance}'",
                attributeName, _instanceName);
        }

        return response.Value;
    }

    /// <summary>
    /// 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
    /// <see cref="InvalidOperationException"/>.
    /// </summary>
    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<SetStaticAttributeResponse>(command, _askTimeout);

        if (!response.Success)
        {
            throw new InvalidOperationException(
                $"SetAttribute('{attributeName}') failed: {response.ErrorMessage}");
        }
    }

    /// <summary>
    /// Calls a sibling script on the same instance by name (Ask pattern).
    /// WP-20: Enforces recursion limit.
    /// WP-22: Uses Ask pattern for CallScript.
    /// <paramref name="parameters"/> may be a dictionary or an anonymous object
    /// (<c>new { name = "Bob" }</c>) — see <see cref="ScriptArgs"/>.
    /// </summary>
    public async Task<object?> 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<ScriptCallResult>(request, _askTimeout);

        if (!result.Success)
        {
            throw new InvalidOperationException(
                $"CallScript('{scriptName}') failed: {result.ErrorMessage}");
        }

        return result.ReturnValue;
    }

    /// <summary>
    /// Provides access to shared script execution via the Scripts property.
    /// </summary>
    public ScriptCallHelper Scripts => new(_sharedScriptLibrary, this, _currentCallDepth, _maxCallDepth, _logger);

    /// <summary>
    /// WP-13: Provides access to external system calls.
    /// ExternalSystem.Call("systemName", "methodName", params)
    /// ExternalSystem.CachedCall("systemName", "methodName", params)
    /// </summary>
    public ExternalSystemHelper ExternalSystem => new(
        _externalSystemClient, _instanceName, _logger, _auditWriter, _siteId, _sourceScript);

    /// <summary>
    /// WP-13: Provides access to database operations.
    /// Database.Connection("name")
    /// Database.CachedWrite("name", "sql", params)
    /// </summary>
    public DatabaseHelper Database => new(_databaseGateway, _instanceName, _logger);

    /// <summary>
    /// Provides access to the Notification Outbox API.
    /// <c>Notify.To("listName").Send("subject", "message")</c> enqueues a notification
    /// for central delivery and returns its <c>NotificationId</c>;
    /// <c>Notify.Status(id)</c> queries the delivery status of that notification.
    /// </summary>
    public NotifyHelper Notify => new(
        _storeAndForward, _siteCommunicationActor, _siteId, _instanceName, _sourceScript, _askTimeout, _logger);

    /// <summary>
    /// Helper class for Scripts.CallShared() syntax.
    /// </summary>
    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;
        }

        /// <summary>
        /// WP-17: Executes a shared script inline (direct method call, not actor message).
        /// WP-20: Enforces recursion limit.
        /// <paramref name="parameters"/> may be a dictionary or an anonymous
        /// object (<c>new { name = "Bob" }</c>) — see <see cref="ScriptArgs"/>.
        /// </summary>
        public async Task<object?> 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);
        }
    }

    /// <summary>
    /// WP-13: Helper for ExternalSystem.Call/CachedCall syntax.
    /// </summary>
    /// <remarks>
    /// Audit Log #23 (M2 Bundle F): every <see cref="Call"/> invocation emits
    /// one <c>ApiOutbound</c>/<c>ApiCall</c> audit row via <see cref="IAuditWriter"/>.
    /// The audit emission is wrapped in a try/catch that swallows every exception
    /// — the audit pipeline is best-effort and must NEVER abort the script's
    /// outbound call (alog.md §7). The original <see cref="ExternalCallResult"/>
    /// (or the original thrown exception) flows back to the caller unchanged.
    /// </remarks>
    public class ExternalSystemHelper
    {
        private static readonly Regex HttpStatusRegex = new(
            @"HTTP\s+(?<code>\d{3})",
            RegexOptions.Compiled | RegexOptions.CultureInvariant);

        private readonly IExternalSystemClient? _client;
        private readonly string _instanceName;
        private readonly ILogger _logger;
        private readonly IAuditWriter? _auditWriter;
        private readonly string _siteId;
        private readonly string? _sourceScript;

        internal ExternalSystemHelper(
            IExternalSystemClient? client,
            string instanceName,
            ILogger logger,
            IAuditWriter? auditWriter = null,
            string siteId = "",
            string? sourceScript = null)
        {
            _client = client;
            _instanceName = instanceName;
            _logger = logger;
            _auditWriter = auditWriter;
            _siteId = siteId;
            _sourceScript = sourceScript;
        }

        public async Task<ExternalCallResult> Call(
            string systemName,
            string methodName,
            IReadOnlyDictionary<string, object?>? parameters = null,
            CancellationToken cancellationToken = default)
        {
            if (_client == null)
                throw new InvalidOperationException("External system client not available");

            // Audit Log #23 (M2 Bundle F): wrap the outbound call so every
            // attempt emits exactly one ApiOutbound/ApiCall row. The wrapper
            // mirrors the existing call-site behaviour — the original result
            // OR original exception flows back to the script untouched; the
            // audit emission is best-effort.
            var occurredAtUtc = DateTime.UtcNow;
            var startTicks = Stopwatch.GetTimestamp();
            ExternalCallResult? result = null;
            Exception? thrown = null;
            try
            {
                result = await _client.CallAsync(systemName, methodName, parameters, cancellationToken);
                return result;
            }
            catch (Exception ex)
            {
                thrown = ex;
                throw;
            }
            finally
            {
                var elapsedMs = (int)((Stopwatch.GetTimestamp() - startTicks)
                    * 1000d / Stopwatch.Frequency);
                EmitCallAudit(systemName, methodName, occurredAtUtc, elapsedMs, result, thrown);
            }
        }

        public async Task<ExternalCallResult> CachedCall(
            string systemName,
            string methodName,
            IReadOnlyDictionary<string, object?>? 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);
        }

        /// <summary>
        /// Best-effort emission of one <c>ApiOutbound</c>/<c>ApiCall</c> audit
        /// row. Any exception thrown by the writer is logged and swallowed —
        /// audit-write failures must never abort the user-facing action.
        /// </summary>
        private void EmitCallAudit(
            string systemName,
            string methodName,
            DateTime occurredAtUtc,
            int durationMs,
            ExternalCallResult? result,
            Exception? thrown)
        {
            if (_auditWriter == null)
            {
                return;
            }

            AuditEvent evt;
            try
            {
                evt = BuildCallAuditEvent(systemName, methodName, occurredAtUtc, durationMs, result, thrown);
            }
            catch (Exception buildEx)
            {
                // Building the event itself must never propagate. This is a
                // defensive guard — populating a record from already-validated
                // values shouldn't throw, but we honour the alog.md §7
                // best-effort contract regardless.
                _logger.LogWarning(buildEx,
                    "Failed to build Audit Log #23 event for {System}.{Method} — skipping emission",
                    systemName, methodName);
                return;
            }

            try
            {
                // Fire-and-forget so we never block the script on the audit
                // writer; the writer itself is responsible for fast, durable
                // enqueue (site SQLite hot-path). We DO observe failures via
                // ContinueWith so a thrown writer is logged rather than going
                // to the unobserved-task firehose.
                var writeTask = _auditWriter.WriteAsync(evt, CancellationToken.None);
                if (!writeTask.IsCompleted)
                {
                    writeTask.ContinueWith(
                        t => _logger.LogWarning(t.Exception,
                            "Audit Log #23 write failed for EventId {EventId} ({System}.{Method})",
                            evt.EventId, systemName, methodName),
                        CancellationToken.None,
                        TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
                        TaskScheduler.Default);
                }
                else if (writeTask.IsFaulted)
                {
                    _logger.LogWarning(writeTask.Exception,
                        "Audit Log #23 write failed for EventId {EventId} ({System}.{Method})",
                        evt.EventId, systemName, methodName);
                }
            }
            catch (Exception writeEx)
            {
                // Synchronous throw from WriteAsync (e.g. ArgumentNullException
                // before the writer's own try/catch). Swallow + log per the
                // alog.md §7 contract.
                _logger.LogWarning(writeEx,
                    "Audit Log #23 write threw synchronously for EventId {EventId} ({System}.{Method})",
                    evt.EventId, systemName, methodName);
            }
        }

        private AuditEvent BuildCallAuditEvent(
            string systemName,
            string methodName,
            DateTime occurredAtUtc,
            int durationMs,
            ExternalCallResult? result,
            Exception? thrown)
        {
            // Status: Delivered on a Success result; Failed otherwise (the
            // ExternalSystemClient already maps HTTP non-2xx + transient
            // exceptions into Success=false on the result, or surfaces a raw
            // exception). M2 makes no distinction between transient + permanent
            // failure here — both manifest as Status.Failed on the sync path.
            var status = (thrown == null && result != null && result.Success)
                ? AuditStatus.Delivered
                : AuditStatus.Failed;

            string? errorMessage = null;
            string? errorDetail = null;
            int? httpStatus = null;

            if (thrown != null)
            {
                errorMessage = thrown.Message;
                errorDetail = thrown.ToString();
            }
            else if (result != null && !result.Success)
            {
                errorMessage = result.ErrorMessage;
                // The ExternalSystemClient embeds the HTTP status code in the
                // error message as "HTTP {code}". Parse it back out so the
                // audit row carries the structured value.
                if (!string.IsNullOrEmpty(result.ErrorMessage))
                {
                    var match = HttpStatusRegex.Match(result.ErrorMessage);
                    if (match.Success
                        && int.TryParse(match.Groups["code"].Value, out var parsed))
                    {
                        httpStatus = parsed;
                    }
                }
            }

            return new AuditEvent
            {
                EventId = Guid.NewGuid(),
                OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
                Channel = AuditChannel.ApiOutbound,
                Kind = AuditKind.ApiCall,
                CorrelationId = null,
                SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
                SourceInstanceId = _instanceName,
                SourceScript = _sourceScript,
                Actor = null,
                Target = $"{systemName}.{methodName}",
                Status = status,
                HttpStatus = httpStatus,
                DurationMs = durationMs,
                ErrorMessage = errorMessage,
                ErrorDetail = errorDetail,
                RequestSummary = null,
                ResponseSummary = null,
                PayloadTruncated = false,
                Extra = null,
                ForwardState = AuditForwardState.Pending,
            };
        }
    }

    /// <summary>
    /// WP-13: Helper for Database.Connection/CachedWrite syntax.
    /// </summary>
    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<System.Data.Common.DbConnection> 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<string, object?>? parameters = null,
            CancellationToken cancellationToken = default)
        {
            if (_gateway == null)
                throw new InvalidOperationException("Database gateway not available");

            await _gateway.CachedWriteAsync(name, sql, parameters, _instanceName, cancellationToken);
        }
    }

    /// <summary>
    /// Notification Outbox: helper for the <c>Notify</c> script API.
    ///
    /// In the outbox design the site no longer delivers notification email inline.
    /// <c>Notify.To("listName").Send(...)</c> enqueues the notification into the site
    /// Store-and-Forward Engine — which forwards it to central — and returns a
    /// <c>NotificationId</c> handle immediately. <c>Notify.Status(id)</c> later queries
    /// the delivery status of that notification.
    /// </summary>
    public class NotifyHelper
    {
        private readonly StoreAndForwardService? _storeAndForward;
        private readonly ICanTell? _siteCommunicationActor;
        private readonly string _siteId;
        private readonly string _instanceName;
        private readonly string? _sourceScript;
        private readonly TimeSpan _askTimeout;
        private readonly ILogger _logger;

        internal NotifyHelper(
            StoreAndForwardService? storeAndForward,
            ICanTell? siteCommunicationActor,
            string siteId,
            string instanceName,
            string? sourceScript,
            TimeSpan askTimeout,
            ILogger logger)
        {
            _storeAndForward = storeAndForward;
            _siteCommunicationActor = siteCommunicationActor;
            _siteId = siteId;
            _instanceName = instanceName;
            _sourceScript = sourceScript;
            _askTimeout = askTimeout;
            _logger = logger;
        }

        /// <summary>
        /// Selects the notification list to send to.
        /// </summary>
        public NotifyTarget To(string listName)
        {
            return new NotifyTarget(
                listName, _storeAndForward, _siteId, _instanceName, _sourceScript, _logger);
        }

        /// <summary>
        /// Queries the delivery status of a previously-sent notification.
        ///
        /// The query is issued to central via the site communication actor. While the
        /// notification is still buffered in the site Store-and-Forward Engine — central
        /// has no row for it yet (<c>Found: false</c>) but the buffer still holds the id —
        /// the status is reported as the site-local <c>Forwarding</c> state. If central
        /// has a row, its status is mapped through verbatim. If central does not know the
        /// id and it is not buffered locally, the status is <c>Unknown</c>.
        /// </summary>
        public async Task<NotificationDeliveryStatus> Status(string notificationId)
        {
            if (_siteCommunicationActor == null)
                throw new InvalidOperationException(
                    "Notification status query is not available — site communication actor not wired");

            var correlationId = Guid.NewGuid().ToString();
            var query = new NotificationStatusQuery(correlationId, notificationId);

            NotificationStatusResponse response;
            try
            {
                response = await _siteCommunicationActor
                    .Ask<NotificationStatusResponse>(query, _askTimeout);
            }
            catch (Exception ex)
            {
                // Central could not be reached. Fall through to the buffer check: if the
                // notification is still in the local S&F buffer it is Forwarding.
                _logger.LogWarning(ex,
                    "Notification status query for {NotificationId} did not reach central",
                    notificationId);
                response = new NotificationStatusResponse(
                    correlationId, Found: false, Status: "Unknown",
                    RetryCount: 0, LastError: null, DeliveredAt: null);
            }

            if (response.Found)
            {
                return new NotificationDeliveryStatus(
                    response.Status, response.RetryCount, response.LastError, response.DeliveredAt);
            }

            // Central has no row. If the notification is still buffered at the site it
            // is in transit — report the site-local Forwarding state. Otherwise it is
            // genuinely unknown (never sent, or already forwarded and central lost it).
            if (_storeAndForward != null)
            {
                var buffered = await _storeAndForward.GetMessageByIdAsync(notificationId);
                if (buffered != null)
                {
                    return new NotificationDeliveryStatus(
                        "Forwarding", buffered.RetryCount, buffered.LastError, DeliveredAt: null);
                }
            }

            return new NotificationDeliveryStatus("Unknown", 0, null, null);
        }
    }

    /// <summary>
    /// Notification Outbox: target for <c>Notify.To("listName").Send(...)</c>.
    /// </summary>
    public class NotifyTarget
    {
        private readonly string _listName;
        private readonly StoreAndForwardService? _storeAndForward;
        private readonly string _siteId;
        private readonly string _instanceName;
        private readonly string? _sourceScript;
        private readonly ILogger _logger;

        internal NotifyTarget(
            string listName,
            StoreAndForwardService? storeAndForward,
            string siteId,
            string instanceName,
            string? sourceScript,
            ILogger logger)
        {
            _listName = listName;
            _storeAndForward = storeAndForward;
            _siteId = siteId;
            _instanceName = instanceName;
            _sourceScript = sourceScript;
            _logger = logger;
        }

        /// <summary>
        /// Enqueues a notification for central delivery and returns its
        /// <c>NotificationId</c> immediately.
        ///
        /// The notification is buffered into the site Store-and-Forward Engine under the
        /// <see cref="StoreAndForwardCategory.Notification"/> category; the S&amp;F
        /// engine's <c>NotificationForwarder</c> forwards it to central and treats
        /// central's ack as the delivery outcome. The returned <c>NotificationId</c> is
        /// the single idempotency key end-to-end: it is the S&amp;F message id, it is
        /// carried inside the buffered payload, and it is the id the forwarder submits to
        /// central. Pass it to <see cref="NotifyHelper.Status"/> to track delivery.
        /// </summary>
        public async Task<string> Send(
            string subject,
            string message,
            CancellationToken cancellationToken = default)
        {
            if (_storeAndForward == null)
                throw new InvalidOperationException(
                    "Notification store-and-forward engine not available");

            // The script controls the idempotency key: generate the NotificationId here,
            // use it as the S&F message id, and carry it inside the buffered payload so
            // the forwarder submits the same id to central on every retry.
            var notificationId = Guid.NewGuid().ToString("N");

            var payload = new NotificationSubmit(
                NotificationId: notificationId,
                ListName: _listName,
                Subject: subject,
                Body: message,
                // SourceSiteId is re-stamped by the forwarder from its own site id; this
                // value is the best-effort site id known to the script runtime.
                SourceSiteId: _siteId,
                SourceInstanceId: _instanceName,
                // SourceScript (FU3): identifier of the script that raised this
                // notification, threaded down from the script-execution context for the
                // central audit trail. Null when no single script owns the context.
                SourceScript: _sourceScript,
                SiteEnqueuedAt: DateTimeOffset.UtcNow);

            var payloadJson = JsonSerializer.Serialize(payload);

            // The S&F engine assigns its own GUID to the message; pin the message id to
            // the NotificationId so the buffer can be queried by it (Notify.Status) and
            // the forwarder's idempotency key matches the buffered row.
            await _storeAndForward.EnqueueAsync(
                StoreAndForwardCategory.Notification,
                target: _listName,
                payloadJson: payloadJson,
                originInstanceName: _instanceName,
                messageId: notificationId);

            _logger.LogDebug(
                "Notify enqueued notification {NotificationId} to list '{List}' for central delivery",
                notificationId, _listName);

            return notificationId;
        }
    }
}
