using System.Collections.Concurrent;
using System.Text.Json;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Observability;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;

namespace ZB.MOM.WW.ScadaBridge.InboundAPI;

/// <summary>
/// Executes the C# script associated with an inbound API method.
/// Compiles method scripts via Roslyn and caches compiled delegates.
/// </summary>
public class InboundScriptExecutor
{
    private readonly ILogger<InboundScriptExecutor> _logger;

    // A cached compiled handler, paired with the exact script text it was
    // compiled from. The DB row is authoritative: on every request the cached
    // Script is ordinal-compared against the freshly-fetched ApiMethod.Script; a
    // mismatch means the cache is stale (a standby node compiled its own copy at
    // startup, or the row was edited via direct SQL / bundle import) and the
    // handler is recompiled in place. A null Script means "no revision info,
    // serve as-is" — the RegisterHandler test seam, exempt from revision checks.
    private sealed record CachedHandler(string? Script, Func<InboundScriptContext, Task<object?>> Handler);

    // This executor is registered as a singleton and its handler cache
    // is read and written from concurrent ASP.NET request threads. A plain Dictionary is
    // not safe for concurrent read/write, so a ConcurrentDictionary is used throughout.
    private readonly ConcurrentDictionary<string, CachedHandler> _scriptHandlers = new();

    // A script that fails to compile (or violates the trust model)
    // is recorded here — keyed by method name, valued by the exact script text
    // that failed — so it is compiled at most once. Without this, every subsequent
    // request for a broken method re-runs the expensive Roslyn compilation — a CPU
    // amplification vector since the inbound API has no rate limiting. A record only
    // short-circuits when the CURRENT script text is the one that failed: a changed
    // script always gets a fresh compile. The entry is cleared whenever the method
    // is (re)compiled successfully.
    //
    // Bound the cache so a spam attack of unique method names cannot
    // grow it without bound. Once the cap is reached new bad-method records are
    // dropped — the cache is just a fast-fail optimisation; the per-request DB
    // lookup remains the correctness path.
    private const int KnownBadMethodsCap = 1000;
    private readonly ConcurrentDictionary<string, string> _knownBadMethods = new();

    /// <summary>
    /// Diagnostic helper — returns the current size of the
    /// known-bad-methods cache so tests can assert the cap is honoured. Internal
    /// so the cache itself stays an implementation detail.
    /// </summary>
    internal int KnownBadMethodCount => _knownBadMethods.Count;

    /// <summary>
    /// Records <paramref name="methodName"/> → <paramref name="script"/> in the
    /// known-bad-methods cache so the CURRENT failing script text short-circuits the
    /// next request. An existing record for the method is overwritten so a newer bad
    /// script replaces an older one (the fast-fail always tracks the latest failure).
    /// A brand-new record is only added while the cache has not reached
    /// <see cref="KnownBadMethodsCap"/>; once full, new method names are dropped
    /// (paying the cheap recompile next time rather than leaking memory under a
    /// unique-name flood). Records are cleared on a successful (re)compile.
    /// </summary>
    private void TryRecordBadMethod(string methodName, string script)
    {
        // Overwrite an existing record (newer bad script wins) without counting
        // against the cap; only a genuinely new method name is gated by the cap.
        if (_knownBadMethods.ContainsKey(methodName))
        {
            _knownBadMethods[methodName] = script;
            return;
        }
        if (_knownBadMethods.Count >= KnownBadMethodsCap)
            return;
        _knownBadMethods.TryAdd(methodName, script);
    }

    private readonly IServiceProvider _serviceProvider;

    // Count of inbound executions whose handler outlived its request (timed out or
    // client-aborted) and is still running with its DI scope disposal deferred. A
    // non-cooperative script that ignores the cancellation token keeps running after
    // WaitAsync returns; the scope is disposed by the handler's own completion
    // continuation, not the request finally, and this gauge makes the pile-up visible.
    private long _abandonedExecutions;

    /// <summary>
    /// Diagnostic gauge — number of inbound executions currently orphaned (handler
    /// still running after a timeout/abort, with DI-scope disposal deferred to the
    /// handler's completion). Internal so the counter stays an implementation detail;
    /// promote to public if a health surface needs it later.
    /// </summary>
    internal long AbandonedExecutionCount => Interlocked.Read(ref _abandonedExecutions);

    /// <summary>
    /// Initializes a new instance of the InboundScriptExecutor.
    /// </summary>
    /// <param name="logger">Logger instance.</param>
    /// <param name="serviceProvider">Service provider for dependency resolution.</param>
    public InboundScriptExecutor(ILogger<InboundScriptExecutor> logger, IServiceProvider serviceProvider)
    {
        _logger = logger;
        _serviceProvider = serviceProvider;
    }

    /// <summary>
    /// Registers a compiled script handler for a method name. This is the script-less
    /// test seam: the handler is stored with no associated script text, so it is
    /// <b>exempt from the per-request revision check</b> and serves as-is regardless of
    /// the fetched row's script. Production compiles always flow through
    /// <see cref="CompileAndRegister(ApiMethod)"/>, which records the script text and is
    /// revision-checked.
    /// </summary>
    /// <param name="methodName">The method name to register.</param>
    /// <param name="handler">The compiled handler function.</param>
    public void RegisterHandler(string methodName, Func<InboundScriptContext, Task<object?>> handler)
    {
        _scriptHandlers[methodName] = new CachedHandler(null, handler);
    }

    /// <summary>
    /// Drops the compiled handler AND any known-bad record for the method. The
    /// consumption seam for the script-artifact invalidation contract (bundle import,
    /// out-of-band updates, failover activation). Safe to call for unknown names.
    /// The per-request revision check in ExecuteAsync remains the correctness
    /// fallback when an invalidation is missed.
    /// </summary>
    /// <param name="methodName">The method name to invalidate.</param>
    public void InvalidateMethod(string methodName)
    {
        _scriptHandlers.TryRemove(methodName, out _);
        _knownBadMethods.TryRemove(methodName, out _);
    }

    /// <summary>
    /// Removes a compiled script handler for a method name. Delegates to
    /// <see cref="InvalidateMethod"/> (public API preserved).
    /// </summary>
    /// <param name="methodName">The method name to remove.</param>
    public void RemoveHandler(string methodName) => InvalidateMethod(methodName);

    /// <summary>
    /// Compiles and registers a single API method script. Returns <c>false</c> if the
    /// script is empty, fails Roslyn compilation, or violates the script trust model.
    /// </summary>
    /// <param name="method">The API method to compile and register.</param>
    /// <returns>True if successfully compiled and registered, false otherwise.</returns>
    public bool CompileAndRegister(ApiMethod method) => CompileAndRegister(method, out _);

    /// <summary>
    /// Compiles and registers a single API method script, additionally reporting the
    /// compilation diagnostics. Returns <c>false</c> together with the human-readable
    /// error list if the script is empty, fails Roslyn compilation, or violates the
    /// script trust model. On failure this method does not overwrite any previously
    /// registered handler, but the persisted DB row is authoritative: <see cref="ExecuteAsync"/>
    /// revision-checks the cached handler against the freshly-fetched
    /// <c>ApiMethod.Script</c> on every request, so once a broken script is saved the
    /// method returns a compilation error consistently on every node rather than the
    /// stale delegate silently serving. The management Create/Update path can therefore
    /// surface a "saved but did not compile" warning that reflects real, consistent
    /// behavior (see <c>HandleCreateApiMethod</c>/<c>HandleUpdateApiMethod</c>).
    /// </summary>
    /// <param name="method">The API method to compile and register.</param>
    /// <param name="errors">On failure, the compilation/trust-model error messages; empty on success.</param>
    /// <returns>True if successfully compiled and registered, false otherwise.</returns>
    public bool CompileAndRegister(ApiMethod method, out IReadOnlyList<string> errors)
    {
        var (handler, compileErrors) = Compile(method);
        if (handler == null)
        {
            // Record the failure so the lazy-compile path does not
            // keep recompiling a broken script on every request. Routed
            // through the capped TryRecordBadMethod helper so the cache
            // cannot grow without bound under a flood of unique method names.
            errors = compileErrors;
            TryRecordBadMethod(method.Name, method.Script ?? string.Empty);
            return false;
        }

        // The method definition was (re)compiled successfully — drop any stale
        // failure record so a fixed script is no longer treated as bad.
        errors = Array.Empty<string>();
        _knownBadMethods.TryRemove(method.Name, out _);
        return Register(method, handler);
    }

    private bool Register(ApiMethod method, Func<InboundScriptContext, Task<object?>> handler)
    {
        _scriptHandlers[method.Name] = new CachedHandler(method.Script, handler);
        return true;
    }

    /// <summary>
    /// Compiles a single API method script into an executable handler. Returns
    /// <c>null</c> when the script is missing, fails to compile, or violates the
    /// script trust model. Does not mutate the handler cache.
    /// </summary>
    /// <summary>
    /// Roslyn scripting options for every inbound-API method compile.
    ///
    /// <para>
    /// <b>Built ONCE, deliberately. Do not inline this back into <see cref="Compile"/>.</b>
    /// <c>WithReferences(Assembly[])</c> resolves each assembly through
    /// <c>MetadataReference.CreateFromFile</c>, which does not cache — every call mints a
    /// fresh <c>AssemblyMetadata</c> → <c>PEReader</c> → <c>NativeHeapMemoryBlock</c> holding
    /// an unmanaged copy of the assembly metadata that nothing disposes. Per-compile options
    /// therefore leak native memory for the life of the process, invisibly to the GC and to
    /// gcdump. Method compiles are not one-shot: every method re-registration and every
    /// revision change recompiles, so the growth is unbounded on a long-lived central node.
    /// </para>
    ///
    /// <para>
    /// Same defect, same shape as the Site-side one confirmed from a live dump on 2026-08-12
    /// (<c>SiteRuntime.Scripts.ScriptCompilationService.SharedScriptOptions</c>).
    /// </para>
    ///
    /// <para>
    /// Static options alone are NOT enough: every method compile binds the
    /// transitive closure of these references, and Roslyn's default resolver
    /// re-resolves that closure through <c>MetadataReference.CreateFromFile</c> on
    /// EVERY compile (confirmed live post-5a781c70 on 2026-08-12: 6 inbound
    /// methods held 2,699 AssemblyMetadata/PEReader/MetadataImageReference
    /// objects, ~150 per compile — and method revisions recompile, so growth was
    /// unbounded). The shared <see cref="CachingScriptMetadataResolver"/>
    /// memoizes those resolutions process-wide.
    /// </para>
    /// </summary>
    private static readonly ScriptOptions SharedScriptOptions = ScriptOptions.Default
        .WithReferences(
            typeof(object).Assembly,
            typeof(Enumerable).Assembly,
            typeof(Dictionary<,>).Assembly,
            typeof(RouteHelper).Assembly,
            typeof(ScriptParameters).Assembly,
            typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly)
        .WithMetadataResolver(CachingScriptMetadataResolver.Instance)
        .WithImports(
            "System",
            "System.Collections.Generic",
            "System.Linq",
            "System.Threading.Tasks");

    private (Func<InboundScriptContext, Task<object?>>? Handler, IReadOnlyList<string> Errors) Compile(ApiMethod method)
    {
        if (string.IsNullOrWhiteSpace(method.Script))
        {
            _logger.LogWarning("API method {Method} has no script code", method.Name);
            return (null, new[] { "Method has no script code." });
        }

        // Enforce the script trust model before compiling. Roslyn
        // scripting performs no API allow/deny-listing, so forbidden namespaces must
        // be rejected statically or the script could reach the host process.
        var violations = ForbiddenApiChecker.FindViolations(method.Script);
        if (violations.Count > 0)
        {
            _logger.LogWarning(
                "API method {Method} script rejected — trust model violation(s): {Violations}",
                method.Name, string.Join("; ", violations));
            return (null, violations.Select(v => "Trust model violation: " + v).ToList());
        }

        try
        {
            var compiled = CSharpScript.Create<object?>(
                method.Script,
                SharedScriptOptions,
                globalsType: typeof(InboundScriptContext));

            var diagnostics = compiled.Compile();
            var errors = diagnostics
                .Where(d => d.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error)
                .Select(d => d.GetMessage())
                .ToList();

            if (errors.Count > 0)
            {
                _logger.LogWarning(
                    "API method {Method} script compilation failed: {Errors}",
                    method.Name, string.Join("; ", errors));
                return (null, errors);
            }

            _logger.LogInformation("API method {Method} script compiled", method.Name);
            return (async ctx =>
            {
                var state = await compiled.RunAsync(ctx, ctx.CancellationToken);
                return state.ReturnValue;
            }, Array.Empty<string>());
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to compile API method {Method} script", method.Name);
            return (null, new[] { "Compilation error: " + ex.Message });
        }
    }

    /// <summary>
    /// Executes the script for the given method with the provided context.
    /// </summary>
    /// <param name="method">The API method containing the script to execute.</param>
    /// <param name="parameters">Input parameters for the script.</param>
    /// <param name="route">Route helper for cross-site routing.</param>
    /// <param name="timeout">Timeout duration for script execution.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <param name="parentExecutionId">
    /// ParentExecutionId: the inbound API request's per-request
    /// <c>ExecutionId</c> (minted early by <c>AuditWriteMiddleware</c> and stashed
    /// on <c>HttpContext.Items</c>). When supplied, a routed
    /// <c>Route.To(...).Call(...)</c> inside the script carries it as
    /// <see cref="RouteToCallRequest.ParentExecutionId"/> so the spawned site
    /// script execution points back at this inbound request. Null when the script
    /// runs outside an inbound API request flow.
    /// </param>
    /// <returns>The result of executing the script.</returns>
    public async Task<InboundScriptResult> ExecuteAsync(
        ApiMethod method,
        IReadOnlyDictionary<string, object?> parameters,
        RouteHelper route,
        TimeSpan timeout,
        CancellationToken cancellationToken = default,
        // Deliberate ordering: this optional parameter trails the CancellationToken
        // because it was appended additively for non-breaking contract evolution.
        // Every call site passes it by named argument (parentExecutionId:).
        Guid? parentExecutionId = null)
    {
        // Keep the timeout source and the request-abort source
        // separable. A single linked CTS makes a genuine client disconnect
        // indistinguishable from a method timeout, so a normal disconnect would be
        // logged and reported as "Script execution timed out". Use a dedicated
        // timeout CTS, linked with the request token, so the two can be told apart.
        using var timeoutCts = new CancellationTokenSource(timeout);
        using var cts = CancellationTokenSource.CreateLinkedTokenSource(
            cancellationToken, timeoutCts.Token);

        // IpsenMES MoveIn: expose the scoped, parameterized Database helper to the script
        // (reads + writes). Resolve the gateway from a fresh DI scope
        // (declared outside the try so it lives until after the script handler runs and is
        // disposed here) so a scoped IDatabaseGateway is honoured. GetService (not
        // GetRequiredService) so a method that never touches Database still runs even when
        // no IDatabaseGateway is registered; the helper throws on first use if built
        // without a gateway.
        //
        // A service provider that cannot produce a scope (e.g. a bare test double with
        // no IServiceScopeFactory) is tolerated: the gateway is simply unavailable, so
        // a script not using Database still runs, while one that does fails on use.
        IServiceScope? scope = null;
        // Hoisted out of the try so the finally can tell whether the handler task
        // outlived the request. If it did (non-cooperative script after a timeout or
        // client abort), disposing the scope underneath it would fault the still-running
        // task with ObjectDisposedException — so the finally defers disposal instead.
        Task<object?>? handlerTask = null;
        try
        {
            scope = _serviceProvider.CreateScope();
        }
        catch (InvalidOperationException ex)
        {
            // No scope factory available (e.g. a non-scoping test-double provider).
            // In production this should never happen; log so a genuine container
            // misconfiguration is visible rather than silently disabling Database.
            _logger.LogWarning(ex,
                "Could not create a DI scope for method {Method}; Database will be unavailable to its script",
                method.Name);
        }

        try
        {
            var gateway = scope?.ServiceProvider.GetService<IDatabaseGateway>();
            // Pass the method timeout so the helper derives a
            // CommandTimeout backstop and forwards cts.Token to every async DB call —
            // a slow query is then bounded by the method deadline.
            var dbHelper = new InboundDatabaseHelper(gateway, cts.Token, timeout);

            // Bind the route helper to the method deadline so a
            // routed Route.To(...).Call(...) inherits the method-level timeout
            // without the script having to thread the context token by hand.
            //
            // Also pass the raw request-abort token separately so
            // Route.To(...).WaitForAttribute(...) can be bounded by its WAIT timeout
            // (not the generic method deadline) while still being cancelled by a
            // client disconnect — see RouteTarget.WaitForAttribute.
            //
            // Also bind the inbound request's ExecutionId (ParentExecutionId) so a
            // routed call carries it as ParentExecutionId — the
            // spawned site script execution points back at this inbound request.
            var context = new InboundScriptContext(
                parameters,
                route.WithDeadline(cts.Token)
                     .WithRequestAborted(cancellationToken)
                     .WithParentExecutionId(parentExecutionId),
                dbHelper,
                cts.Token);

            _scriptHandlers.TryGetValue(method.Name, out var cached);
            // Revision check: a cached handler compiled from different script text is stale
            // (standby-after-failover, direct SQL edit, bundle import). Null Script = test seam, exempt.
            if (cached is { Script: not null }
                && !string.Equals(cached.Script, method.Script, StringComparison.Ordinal))
            {
                _logger.LogInformation(
                    "API method {Method} script changed since it was cached; recompiling", method.Name);
                cached = null;
            }

            if (cached == null)
            {
                // Fast-fail only when the CURRENT script text is the one already known bad.
                if (_knownBadMethods.TryGetValue(method.Name, out var badScript)
                    && string.Equals(badScript, method.Script, StringComparison.Ordinal))
                    return new InboundScriptResult(false, null, "Script compilation failed for this method", "SCRIPT_COMPILE_FAILED");

                // Lazy compile (handles methods created after startup, and stale/known-bad
                // recompiles). Compile outside the cache so a failed compile is not stored,
                // then add so concurrent first-callers share a single handler instance.
                var (compiled, _) = Compile(method);
                if (compiled == null)
                {
                    // Cache the failure so the next request short-circuits above.
                    // Routed through TryRecordBadMethod so the
                    // cache is bounded under a flood of unique method names.
                    TryRecordBadMethod(method.Name, method.Script ?? string.Empty);
                    return new InboundScriptResult(false, null, "Script compilation failed for this method", "SCRIPT_COMPILE_FAILED");
                }

                _knownBadMethods.TryRemove(method.Name, out _);
                cached = new CachedHandler(method.Script, compiled);
                _scriptHandlers[method.Name] = cached; // last-write-wins; concurrent first-callers race benignly
            }

            handlerTask = cached.Handler(context);
            var result = await handlerTask.WaitAsync(cts.Token);

            var resultJson = result != null
                ? JsonSerializer.Serialize(result)
                : null;

            // Thread the JSON-Schema $ref resolution seam into return
            // validation so a method whose ReturnDefinition uses a {"$ref":"lib:Name"}
            // resolves the reference at RUNTIME (not just at deploy time). The
            // shared-schema library is pre-loaded ONCE from the per-execution DI scope
            // (the seam the validator consumes is synchronous), and ONLY when the
            // definition actually uses a $ref — a $ref-free return definition skips the
            // repository round-trip entirely. A dangling ref surfaces as a descriptive
            // validation failure naming the missing reference, not an opaque parse error.
            var sharedSchemaRepo =
                scope?.ServiceProvider.GetService<ISharedSchemaRepository>();
            var resolveRef = await SchemaRefResolver.BuildAsync(
                sharedSchemaRepo, [method.ReturnDefinition], cts.Token);

            // Validate the script's return value against the
            // method's declared ReturnDefinition. A method whose script returns a
            // shape inconsistent with its definition must not silently emit a
            // malformed 200 — surface it as a script failure (500) and log.
            var returnValidation = ReturnValueValidator.Validate(resultJson, method.ReturnDefinition, resolveRef);
            if (!returnValidation.IsValid)
            {
                _logger.LogWarning(
                    "API method {Method} return value rejected: {Error}",
                    method.Name, returnValidation.ErrorMessage);
                return new InboundScriptResult(false, null, "Method return value did not match its return definition");
            }

            return new InboundScriptResult(true, resultJson, null);
        }
        catch (OperationCanceledException)
        {
            // Distinguish a genuine method timeout from a client
            // abort. Only the timeout CTS firing is a real timeout; if the caller's
            // request token fired, the client disconnected — do not pollute the
            // timeout log (reserved for genuine script-execution timeouts).
            if (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
            {
                _logger.LogWarning("Script execution timed out for method {Method}", method.Name);
                return new InboundScriptResult(false, null, "Script execution timed out", "TIMEOUT");
            }

            _logger.LogDebug("Inbound API request for method {Method} cancelled by client", method.Name);
            return new InboundScriptResult(false, null, "Request cancelled by client");
        }
        catch (SiteUnreachableException)
        {
            // C3: a routed Route.To(...).Call(...) reached a site that could not be
            // contacted. Surface the machine-readable SITE_UNREACHABLE code (spec
            // Component-InboundAPI failure body) instead of the generic SCRIPT_ERROR.
            _logger.LogWarning("Routed site unreachable for method {Method}", method.Name);
            return new InboundScriptResult(false, null, "Site unreachable", "SITE_UNREACHABLE");
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Script execution failed for method {Method}", method.Name);
            // Safe error message, no internal details
            return new InboundScriptResult(false, null, "Internal script error", "SCRIPT_ERROR");
        }
        finally
        {
            if (handlerTask is null || handlerTask.IsCompleted)
            {
                scope?.Dispose();
            }
            else
            {
                // The handler outlived the request (non-cooperative script after a
                // timeout/client abort). Do NOT dispose the scope underneath it —
                // defer cleanup to the task's own completion, and count the orphan
                // so repeated timeouts of a slow method are visible.
                Interlocked.Increment(ref _abandonedExecutions);
                ScadaBridgeTelemetry.RecordInboundAbandonedExecution(method.Name);
                _logger.LogWarning(
                    "Abandoned inbound execution for method {Method} is still running; DI scope disposal deferred", method.Name);
                var abandonedScope = scope;
                _ = handlerTask.ContinueWith(t =>
                {
                    _ = t.Exception; // observe the fault so it never surfaces as unobserved
                    abandonedScope?.Dispose();
                    Interlocked.Decrement(ref _abandonedExecutions);
                }, TaskScheduler.Default);
            }
        }
    }
}

/// <summary>
/// Context provided to inbound API scripts.
/// </summary>
public class InboundScriptContext
{
    /// <summary>
    /// The script parameters.
    /// </summary>
    public ScriptParameters Parameters { get; }

    /// <summary>
    /// The route helper for cross-site routing.
    /// </summary>
    public RouteHelper Route { get; }

    /// <summary>
    /// Scoped, parameterized database access for the script (named connections; bound
    /// parameters; reads and writes — see <see cref="InboundDatabaseHelper"/>).
    /// </summary>
    public InboundDatabaseHelper Database { get; }

    /// <summary>
    /// The cancellation token for script execution.
    /// </summary>
    public CancellationToken CancellationToken { get; }

    /// <summary>
    /// Initializes a new instance of the InboundScriptContext.
    /// </summary>
    /// <param name="parameters">The input parameters for the script.</param>
    /// <param name="route">The route helper for cross-site routing.</param>
    /// <param name="database">The scoped, parameterized database helper for the script.</param>
    /// <param name="cancellationToken">The cancellation token.</param>
    public InboundScriptContext(
        IReadOnlyDictionary<string, object?> parameters,
        RouteHelper route,
        InboundDatabaseHelper database,
        CancellationToken cancellationToken = default)
    {
        Parameters = new ScriptParameters(parameters);
        Route = route;
        Database = database;
        CancellationToken = cancellationToken;
    }
}

/// <summary>
/// Result of executing an inbound API script.
/// </summary>
/// <param name="Success">Whether the script executed successfully.</param>
/// <param name="ResultJson">The serialized script return value on success; null otherwise.</param>
/// <param name="ErrorMessage">A sanitized, caller-safe error message on failure; null on success.</param>
/// <param name="ErrorCode">
/// C3: an additive, machine-readable error code the endpoint surfaces in the failure
/// body's <c>code</c> field (e.g. <c>TIMEOUT</c>, <c>SITE_UNREACHABLE</c>,
/// <c>SCRIPT_COMPILE_FAILED</c>, <c>SCRIPT_ERROR</c>). Null when the executor does not
/// classify the failure; the endpoint then falls back to <c>SCRIPT_ERROR</c>.
/// </param>
public record InboundScriptResult(
    bool Success,
    string? ResultJson,
    string? ErrorMessage,
    string? ErrorCode = null);
