using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;

namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;

/// <summary>
/// Regression coverage for SiteRuntime-016 — the short-lived execution actors
/// (<see cref="ScriptExecutionActor"/>, <see cref="AlarmExecutionActor"/>) were
/// previously untested. Covers success, exception, timeout, Ask-reply, and the
/// PoisonPill self-stop after completion.
/// </summary>
public class ExecutionActorTests : TestKit, IDisposable
{
    private readonly SharedScriptLibrary _sharedLibrary;
    private readonly ScriptCompilationService _compilationService;

    public ExecutionActorTests()
    {
        _compilationService = new ScriptCompilationService(
            NullLogger<ScriptCompilationService>.Instance);
        _sharedLibrary = new SharedScriptLibrary(
            _compilationService, NullLogger<SharedScriptLibrary>.Instance);
    }

    void IDisposable.Dispose() => Shutdown();

    private static Script<object?> CompileScript(string code)
    {
        var scriptOptions = ScriptOptions.Default
            .WithReferences(typeof(object).Assembly, typeof(Enumerable).Assembly)
            .WithImports("System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks");
        var script = CSharpScript.Create<object?>(code, scriptOptions, typeof(ScriptGlobals));
        script.Compile();
        return script;
    }

    private static SiteRuntimeOptions Options(int timeoutSeconds = 30)
        => new() { MaxScriptCallDepth = 10, ScriptExecutionTimeoutSeconds = timeoutSeconds };

    // ── ScriptExecutionActor ──

    [Fact]
    public void ScriptExecutionActor_Success_RepliesWithResultAndStops()
    {
        var compiled = CompileScript("return 7 * 6;");
        var replyTo = CreateTestProbe();
        var instanceActor = CreateTestProbe();

        var exec = ActorOf(Props.Create(() => new ScriptExecutionActor(
            "Answer", "Inst1", compiled, null, 0,
            instanceActor.Ref, _sharedLibrary, Options(),
            replyTo.Ref, "corr-1", NullLogger.Instance,
            ScriptScope.Root, null, null)));

        Watch(exec);

        var result = replyTo.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
        Assert.True(result.Success);
        Assert.Equal("corr-1", result.CorrelationId);
        Assert.Equal(42, result.ReturnValue);

        // The actor must PoisonPill itself once execution completes.
        ExpectTerminated(exec, TimeSpan.FromSeconds(5));
    }

    // ── M1.8: site event log `script` started/completed ────────────────────

    [Fact]
    public void ScriptExecutionActor_Success_EmitsScriptStartedAndCompletedInfoEvents()
    {
        var compiled = CompileScript("return 7 * 6;");
        var replyTo = CreateTestProbe();
        var instanceActor = CreateTestProbe();
        var siteLog = new FakeSiteEventLogger();

        var exec = ActorOf(Props.Create(() => new ScriptExecutionActor(
            "Answer", "Inst1", compiled, null, 0,
            instanceActor.Ref, _sharedLibrary, Options(),
            replyTo.Ref, "corr-evt-1", NullLogger.Instance,
            ScriptScope.Root, null, new SingleServiceProvider(siteLog))));

        Watch(exec);
        replyTo.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
        ExpectTerminated(exec, TimeSpan.FromSeconds(5));

        AwaitAssert(() =>
        {
            var rows = siteLog.OfType("script");
            // started + completed, both Info, in order.
            Assert.Equal(2, rows.Count);
            Assert.All(rows, r =>
            {
                Assert.Equal("Info", r.Severity);
                Assert.Equal("Inst1", r.InstanceId);
                Assert.Equal("ScriptActor:Answer", r.Source);
            });
            Assert.Contains("started", rows[0].Message, StringComparison.OrdinalIgnoreCase);
            Assert.Contains("completed", rows[1].Message, StringComparison.OrdinalIgnoreCase);
        }, TimeSpan.FromSeconds(2));
    }

    [Fact]
    public void ScriptExecutionActor_Failure_EmitsStartedInfoThenErrorEvent()
    {
        var compiled = CompileScript("throw new InvalidOperationException(\"boom\");");
        var replyTo = CreateTestProbe();
        var instanceActor = CreateTestProbe();
        var siteLog = new FakeSiteEventLogger();

        var exec = ActorOf(Props.Create(() => new ScriptExecutionActor(
            "Bad", "Inst1", compiled, null, 0,
            instanceActor.Ref, _sharedLibrary, Options(),
            replyTo.Ref, "corr-evt-2", NullLogger.Instance,
            ScriptScope.Root, null, new SingleServiceProvider(siteLog))));

        Watch(exec);
        replyTo.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
        ExpectTerminated(exec, TimeSpan.FromSeconds(5));

        AwaitAssert(() =>
        {
            var rows = siteLog.OfType("script");
            // started (Info) + failed (Error) — no completed.
            Assert.Equal(2, rows.Count);
            Assert.Equal("Info", rows[0].Severity);
            Assert.Contains("started", rows[0].Message, StringComparison.OrdinalIgnoreCase);
            Assert.Equal("Error", rows[1].Severity);
        }, TimeSpan.FromSeconds(2));
    }

    [Fact]
    public void ScriptExecutionActor_ScriptThrows_RepliesFailureAndStops()
    {
        var compiled = CompileScript("throw new InvalidOperationException(\"boom\");");
        var replyTo = CreateTestProbe();
        var instanceActor = CreateTestProbe();

        var exec = ActorOf(Props.Create(() => new ScriptExecutionActor(
            "Bad", "Inst1", compiled, null, 0,
            instanceActor.Ref, _sharedLibrary, Options(),
            replyTo.Ref, "corr-2", NullLogger.Instance,
            ScriptScope.Root, null, null)));

        Watch(exec);

        var result = replyTo.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
        Assert.False(result.Success);
        Assert.Equal("corr-2", result.CorrelationId);
        Assert.Contains("boom", result.ErrorMessage);

        ExpectTerminated(exec, TimeSpan.FromSeconds(5));
    }

    [Fact]
    public void ScriptExecutionActor_Timeout_RepliesFailureAndStops()
    {
        // A long busy loop that observes the cancellation token so the
        // 1-second timeout fires cooperatively.
        var compiled = CompileScript(
            "while (true) { await System.Threading.Tasks.Task.Delay(50, CancellationToken); }");
        var replyTo = CreateTestProbe();
        var instanceActor = CreateTestProbe();

        var exec = ActorOf(Props.Create(() => new ScriptExecutionActor(
            "Slow", "Inst1", compiled, null, 0,
            instanceActor.Ref, _sharedLibrary, Options(timeoutSeconds: 1),
            replyTo.Ref, "corr-3", NullLogger.Instance,
            ScriptScope.Root, null, null)));

        Watch(exec);

        var result = replyTo.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
        Assert.False(result.Success);
        Assert.Contains("timed out", result.ErrorMessage);

        ExpectTerminated(exec, TimeSpan.FromSeconds(5));
    }

    [Fact]
    public void ScriptExecutionActor_PerScriptTimeout_OverridesLongerGlobal()
    {
        // M2.5 (#9): a short per-script timeout (1s) must win over a long global
        // (300s), so the busy loop is cancelled at the per-script value.
        var compiled = CompileScript(
            "while (true) { await System.Threading.Tasks.Task.Delay(50, CancellationToken); }");
        var replyTo = CreateTestProbe();
        var instanceActor = CreateTestProbe();

        var exec = ActorOf(Props.Create(() => new ScriptExecutionActor(
            "Slow", "Inst1", compiled, null, 0,
            instanceActor.Ref, _sharedLibrary, Options(timeoutSeconds: 300),
            replyTo.Ref, "corr-perscript", NullLogger.Instance,
            ScriptScope.Root, null, null, null,
            /* executionTimeoutSeconds */ 1)));

        Watch(exec);

        var result = replyTo.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
        Assert.False(result.Success);
        Assert.Contains("timed out", result.ErrorMessage);

        ExpectTerminated(exec, TimeSpan.FromSeconds(5));
    }

    [Fact]
    public void ScriptExecutionActor_NullPerScriptTimeout_FallsBackToGlobal()
    {
        // M2.5 (#9): a null per-script timeout falls back to the global (1s here),
        // so the busy loop is still cancelled at the global value.
        var compiled = CompileScript(
            "while (true) { await System.Threading.Tasks.Task.Delay(50, CancellationToken); }");
        var replyTo = CreateTestProbe();
        var instanceActor = CreateTestProbe();

        var exec = ActorOf(Props.Create(() => new ScriptExecutionActor(
            "Slow", "Inst1", compiled, null, 0,
            instanceActor.Ref, _sharedLibrary, Options(timeoutSeconds: 1),
            replyTo.Ref, "corr-fallback", NullLogger.Instance,
            ScriptScope.Root, null, null, null,
            /* executionTimeoutSeconds */ null)));

        Watch(exec);

        var result = replyTo.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
        Assert.False(result.Success);
        Assert.Contains("timed out", result.ErrorMessage);

        ExpectTerminated(exec, TimeSpan.FromSeconds(5));
    }

    [Fact]
    public void ScriptExecutionActor_NonPositivePerScriptTimeout_FallsBackToGlobal()
    {
        // M2.5 (#9): a non-positive per-script value (<= 0) is treated as "use
        // global", so the busy loop is cancelled at the global (1s) value.
        var compiled = CompileScript(
            "while (true) { await System.Threading.Tasks.Task.Delay(50, CancellationToken); }");
        var replyTo = CreateTestProbe();
        var instanceActor = CreateTestProbe();

        var exec = ActorOf(Props.Create(() => new ScriptExecutionActor(
            "Slow", "Inst1", compiled, null, 0,
            instanceActor.Ref, _sharedLibrary, Options(timeoutSeconds: 1),
            replyTo.Ref, "corr-clamp", NullLogger.Instance,
            ScriptScope.Root, null, null, null,
            /* executionTimeoutSeconds */ 0)));

        Watch(exec);

        var result = replyTo.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
        Assert.False(result.Success);
        Assert.Contains("timed out", result.ErrorMessage);

        ExpectTerminated(exec, TimeSpan.FromSeconds(5));
    }

    [Fact]
    public void ScriptExecutionActor_NoReplyTo_StillStopsAfterCompletion()
    {
        var compiled = CompileScript("return 1;");
        var instanceActor = CreateTestProbe();

        // ActorRefs.Nobody as replyTo — fire-and-forget execution.
        var exec = ActorOf(Props.Create(() => new ScriptExecutionActor(
            "FireForget", "Inst1", compiled, null, 0,
            instanceActor.Ref, _sharedLibrary, Options(),
            ActorRefs.Nobody, "corr-4", NullLogger.Instance,
            ScriptScope.Root, null, null)));

        Watch(exec);
        ExpectTerminated(exec, TimeSpan.FromSeconds(5));
    }

    // ── AlarmExecutionActor ──

    [Fact]
    public void AlarmExecutionActor_Success_StopsAfterCompletion()
    {
        var compiled = CompileScript("return 0;");
        var instanceActor = CreateTestProbe();

        var exec = ActorOf(Props.Create(() => new AlarmExecutionActor(
            "HiTemp", "Inst1", AlarmLevel.High, 5, "High temperature",
            compiled, instanceActor.Ref, _sharedLibrary, Options(),
            NullLogger.Instance)));

        Watch(exec);
        ExpectTerminated(exec, TimeSpan.FromSeconds(5));
    }

    [Fact]
    public void AlarmExecutionActor_ScriptThrows_StillStops()
    {
        var compiled = CompileScript("throw new System.Exception(\"alarm-boom\");");
        var instanceActor = CreateTestProbe();

        var exec = ActorOf(Props.Create(() => new AlarmExecutionActor(
            "HiTemp", "Inst1", AlarmLevel.High, 5, "High temperature",
            compiled, instanceActor.Ref, _sharedLibrary, Options(),
            NullLogger.Instance)));

        Watch(exec);
        // Even on a throwing on-trigger body, the actor must self-stop.
        ExpectTerminated(exec, TimeSpan.FromSeconds(5));
    }

    [Fact]
    public void AlarmExecutionActor_PerScriptTimeout_OverridesLongerGlobal()
    {
        // M2.5 (#9): the alarm on-trigger script's per-script timeout (1s) wins
        // over a long global (300s). The busy loop is cancelled and the actor
        // self-stops (the timeout is logged, alarm continues).
        var compiled = CompileScript(
            "while (true) { await System.Threading.Tasks.Task.Delay(50, CancellationToken); }");
        var instanceActor = CreateTestProbe();

        var exec = ActorOf(Props.Create(() => new AlarmExecutionActor(
            "HiTemp", "Inst1", AlarmLevel.High, 5, "High temperature",
            compiled, instanceActor.Ref, _sharedLibrary, Options(timeoutSeconds: 300),
            NullLogger.Instance, /* executionTimeoutSeconds */ 1)));

        Watch(exec);
        // If the per-script timeout were ignored it would block ~300s and this
        // ExpectTerminated would fail; with the override it stops within ~1s.
        ExpectTerminated(exec, TimeSpan.FromSeconds(10));
    }

    [Fact]
    public void AlarmExecutionActor_NullPerScriptTimeout_FallsBackToGlobal()
    {
        // M2.5 (#9): a null per-script timeout falls back to the global (1s here),
        // so the busy loop is cancelled at the global value and the actor self-stops.
        var compiled = CompileScript(
            "while (true) { await System.Threading.Tasks.Task.Delay(50, CancellationToken); }");
        var instanceActor = CreateTestProbe();

        var exec = ActorOf(Props.Create(() => new AlarmExecutionActor(
            "HiTemp", "Inst1", AlarmLevel.High, 5, "High temperature",
            compiled, instanceActor.Ref, _sharedLibrary, Options(timeoutSeconds: 1),
            NullLogger.Instance, /* executionTimeoutSeconds */ null)));

        Watch(exec);
        // Global timeout (1s) must fire even when per-script is null.
        ExpectTerminated(exec, TimeSpan.FromSeconds(10));
    }

    [Fact]
    public void AlarmExecutionActor_NonPositivePerScriptTimeout_FallsBackToGlobal()
    {
        // M2.5 (#9): a non-positive per-script value (<= 0) is treated as "use
        // global", so the busy loop is cancelled at the global (1s) value.
        var compiled = CompileScript(
            "while (true) { await System.Threading.Tasks.Task.Delay(50, CancellationToken); }");
        var instanceActor = CreateTestProbe();

        var exec = ActorOf(Props.Create(() => new AlarmExecutionActor(
            "HiTemp", "Inst1", AlarmLevel.High, 5, "High temperature",
            compiled, instanceActor.Ref, _sharedLibrary, Options(timeoutSeconds: 1),
            NullLogger.Instance, /* executionTimeoutSeconds */ 0)));

        Watch(exec);
        // Non-positive per-script timeout must be ignored; global (1s) must fire.
        ExpectTerminated(exec, TimeSpan.FromSeconds(10));
    }
}
