namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;

/// <summary>
///     Wraps a <see cref="ScriptEvaluator{TContext, TResult}"/> with a per-evaluation
///     wall-clock timeout. Default is 250ms per Phase 7 plan Stream A.4; configurable
///     per tag so deployments with slower backends can widen it.
/// </summary>
/// <remarks>
///     <para>
///         Implemented with <see cref="Task.WaitAsync(TimeSpan, CancellationToken)"/>
///         rather than a cancellation-token-only approach because Roslyn-compiled
///         scripts don't internally poll the cancellation token unless the user code
///         does async work. A CPU-bound infinite loop in a script won't honor a
///         cooperative cancel — <c>WaitAsync</c> returns control when the timeout fires
///         regardless of whether the inner task completes.
///     </para>
///     <para>
///         <b>Known limitation:</b> when a script times out, the underlying ScriptRunner
///         task continues running on a thread-pool thread until the Roslyn runtime
///         returns. In the CPU-bound-infinite-loop case that's effectively "leaked" —
///         the thread is tied up until the runtime decides to return, which it may
///         never do. Phase 7 plan Stream A.4 accepts this as a known trade-off; tighter
///         CPU budgeting would require an out-of-process script runner, which is a v3
///         concern. In practice, the timeout + structured warning log surfaces the
///         offending script so the operator can fix it; the orphan thread is rare.
///     </para>
///     <para>
///         Caller-supplied <see cref="CancellationToken"/> is honored — if the caller
///         cancels before the timeout fires, the caller's cancel wins and the
///         <see cref="OperationCanceledException"/> propagates (not wrapped as
///         <see cref="ScriptTimeoutException"/>). That distinction matters: the
///         virtual-tag engine's shutdown path cancels scripts on dispose; it shouldn't
///         see those as timeouts.
///     </para>
/// </remarks>
public sealed class TimedScriptEvaluator<TContext, TResult>
    where TContext : ScriptContext
{
    /// <summary>Default timeout per Phase 7 plan Stream A.4 — 250ms.</summary>
    public static readonly TimeSpan DefaultTimeout = TimeSpan.FromMilliseconds(250);

    private readonly ScriptEvaluator<TContext, TResult> _inner;

    /// <summary>Wall-clock budget per evaluation. Script exceeding this throws <see cref="ScriptTimeoutException"/>.</summary>
    public TimeSpan Timeout { get; }

    /// <summary>Initializes a new instance of the TimedScriptEvaluator class with the default timeout.</summary>
    /// <param name="inner">The inner script evaluator.</param>
    public TimedScriptEvaluator(ScriptEvaluator<TContext, TResult> inner)
        : this(inner, DefaultTimeout)
    {
    }

    /// <summary>Initializes a new instance of the TimedScriptEvaluator class with a custom timeout.</summary>
    /// <param name="inner">The inner script evaluator.</param>
    /// <param name="timeout">The evaluation timeout duration.</param>
    public TimedScriptEvaluator(ScriptEvaluator<TContext, TResult> inner, TimeSpan timeout)
    {
        _inner = inner ?? throw new ArgumentNullException(nameof(inner));
        if (timeout <= TimeSpan.Zero)
            throw new ArgumentOutOfRangeException(nameof(timeout), "Timeout must be positive.");
        Timeout = timeout;
    }

    /// <summary>Runs the script evaluation with the configured timeout.</summary>
    /// <param name="context">The script context.</param>
    /// <param name="ct">The cancellation token.</param>
    /// <returns>The evaluation result.</returns>
    public async Task<TResult> RunAsync(TContext context, CancellationToken ct = default)
    {
        if (context is null) throw new ArgumentNullException(nameof(context));

        // Push evaluation to a thread-pool thread so a CPU-bound script (e.g. a tight
        // loop with no async work) doesn't hog the caller's thread before WaitAsync
        // gets to register its timeout. Without this, Roslyn's ScriptRunner executes
        // synchronously on the calling thread and returns an already-completed Task,
        // so WaitAsync sees a completed task and never fires the timeout.
        var runTask = Task.Run(() => _inner.RunAsync(context, ct), ct);
        try
        {
            return await runTask.WaitAsync(Timeout, ct).ConfigureAwait(false);
        }
        catch (TimeoutException)
        {
            // WaitAsync's synthesized timeout — the inner task may still be running
            // on its thread-pool thread (known leak documented in the class summary).
            // Wrap so callers can distinguish from user-written timeout logic.
            //
            // The class docs guarantee "caller-supplied cancel wins over timeout".
            // When both fire at nearly the same time, WaitAsync observes them in
            // non-deterministic order, so a cancel that arrives a few µs after the
            // timeout still reaches here as TimeoutException. Re-check the token so
            // the guarantee holds regardless of race ordering.
            if (ct.IsCancellationRequested)
                throw new OperationCanceledException(ct);
            throw new ScriptTimeoutException(Timeout);
        }
    }
}

/// <summary>
///     Thrown when a script evaluation exceeds its configured timeout. The virtual-tag
///     engine (Stream B) catches this + maps the owning tag's quality to
///     <c>BadInternalError</c> per the Phase 7 plan, logging a structured
///     warning with the offending script name so operators can locate + fix it.
/// </summary>
public sealed class ScriptTimeoutException : Exception
{
    /// <summary>Gets the timeout duration that was exceeded.</summary>
    public TimeSpan Timeout { get; }

    /// <summary>Initializes a new instance of the ScriptTimeoutException class.</summary>
    /// <param name="timeout">The timeout duration that was exceeded.</param>
    public ScriptTimeoutException(TimeSpan timeout)
        : base($"Script evaluation exceeded the configured timeout of {timeout.TotalMilliseconds:F1} ms. " +
               "The script was either CPU-bound or blocked on a slow operation; check ctx.Logger output " +
               "around the timeout and consider widening the timeout per tag, simplifying the script, or " +
               "moving heavy work out of the evaluation path.")
    {
        Timeout = timeout;
    }
}
