using Akka.Actor;
using Akka.TestKit;
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.Instance;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Streaming;
using ZB.MOM.WW.ScadaBridge.TestSupport;
using System.Text.Json;

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

/// <summary>
/// Audit Log #23 (M5.4 T4 — <c>ParentExecutionId</c> tag-cascade, alarm leg):
/// an alarm on-trigger script run must chain under the execution whose attribute
/// write fired the alarm, and must stay a tree ROOT when the firing value had no
/// audited origin.
///
/// <para>The cascade is three hops, each pinned here:</para>
/// <list type="number">
/// <item><description>
/// <c>ScriptRuntimeContext.SetAttribute</c> stamps the running script's
/// <c>ExecutionId</c> onto <see cref="SetStaticAttributeCommand.SourceExecutionId"/>.
/// </description></item>
/// <item><description>
/// The Instance Actor carries that id onto the published
/// <see cref="AttributeValueChanged.SourceExecutionId"/>.
/// </description></item>
/// <item><description>
/// The Alarm Actor hands it to the spawned <c>AlarmExecutionActor</c> as the
/// on-trigger run's <c>ParentExecutionId</c> — observable via
/// <c>AlarmActor.LastOnTriggerParentExecutionId</c>.
/// </description></item>
/// </list>
///
/// <para>
/// A value arriving from the Data Connection Layer (external device data) carries
/// no <c>SourceExecutionId</c>, so the alarm it fires is correctly parentless.
/// </para>
/// </summary>
public class AlarmCascadeParentExecutionTests : TestKit, IDisposable
{
    private readonly ScriptCompilationService _compilationService;
    private readonly SharedScriptLibrary _sharedLibrary;
    private readonly SiteRuntimeOptions _options;
    private readonly TestLocalDb _localDb;
    private readonly SiteStorageService _storage;

    public AlarmCascadeParentExecutionTests()
    {
        _compilationService = new ScriptCompilationService(
            NullLogger<ScriptCompilationService>.Instance);
        _sharedLibrary = new SharedScriptLibrary(
            _compilationService, NullLogger<SharedScriptLibrary>.Instance);
        _options = new SiteRuntimeOptions();
        _localDb = TestLocalDb.CreateTemp("alarm-cascade-test");
        _storage = new SiteStorageService(_localDb.Db, NullLogger<SiteStorageService>.Instance);
        _storage.InitializeAsync().GetAwaiter().GetResult();
    }

    void IDisposable.Dispose()
    {
        // TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
        // then dispose the database before deleting — the master connection anchors the WAL.
        Shutdown();
        var path = _localDb.Path;
        _localDb.Dispose();
        TestLocalDb.DeleteFiles(path);
    }

    /// <summary>A trivially-succeeding on-trigger script, so the alarm actually spawns an execution.</summary>
    private Script<object?> OnTriggerScript()
    {
        var compiled = _compilationService.Compile("OnTrigger", "return null;");
        Assert.NotNull(compiled.CompiledScript);
        return compiled.CompiledScript!;
    }

    /// <summary>
    /// Compiles a trigger expression outside the trust validator (mirrors
    /// <c>AlarmActorTests.CompileRawTriggerExpression</c>).
    /// </summary>
    private static Script<object?> CompileTriggerExpression(string expression)
    {
        var opts = ScriptOptions.Default
            .WithReferences(typeof(object).Assembly, typeof(Enumerable).Assembly)
            .WithImports("System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks");
        var script = CSharpScript.Create<object?>(expression, opts, typeof(TriggerExpressionGlobals));
        script.Compile();
        return script;
    }

    /// <summary>
    /// Builds an Alarm Actor as a <c>TestActorRef</c> so the spawn-time
    /// <c>ParentExecutionId</c> can be read off the underlying actor (the spawned
    /// child builds its <c>ScriptRuntimeContext</c> internally and exposes nothing).
    /// </summary>
    private TestActorRef<AlarmActor> BuildAlarm(
        ResolvedAlarm config, out Akka.TestKit.TestProbe instanceProbe,
        Script<object?>? triggerExpression = null)
    {
        var probe = CreateTestProbe();
        instanceProbe = probe;
        return ActorOfAsTestActorRef<AlarmActor>(
            Props.Create(() => new AlarmActor(
                config.CanonicalName, "Pump1", probe.Ref, config,
                OnTriggerScript(), _sharedLibrary, _options,
                NullLogger<AlarmActor>.Instance, triggerExpression)),
            "alarm-" + Guid.NewGuid().ToString("N"));
    }

    private static ResolvedAlarm ValueMatchAlarm() => new()
    {
        CanonicalName = "HighTemp",
        TriggerType = "ValueMatch",
        TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Critical\"}",
        PriorityLevel = 1
    };

    // -------------------------------------------------------------------------
    // Hop 3 — Alarm Actor → AlarmExecutionActor
    // -------------------------------------------------------------------------

    [Fact]
    public void ScriptWrittenValue_FiringAlarm_ParentsOnTriggerRunToTheWritingExecution()
    {
        var writerExecutionId = Guid.NewGuid();
        var alarm = BuildAlarm(ValueMatchAlarm(), out var instanceProbe);

        alarm.Tell(new AttributeValueChanged(
            "Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow,
            SourceExecutionId: writerExecutionId));

        instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
        Assert.Equal(writerExecutionId, alarm.UnderlyingActor.LastOnTriggerParentExecutionId);
    }

    [Fact]
    public void DclOriginatedValue_FiringAlarm_LeavesOnTriggerRunAsATreeRoot()
    {
        // External device data has no spawning execution: null is the CORRECT
        // answer here, not a gap. The on-trigger run is a tree root.
        var alarm = BuildAlarm(ValueMatchAlarm(), out var instanceProbe);

        alarm.Tell(new AttributeValueChanged(
            "Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow));

        // The alarm really did raise (and therefore really did spawn the
        // on-trigger run) — so the null below is a recorded root, not a no-op.
        instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
        Assert.Null(alarm.UnderlyingActor.LastOnTriggerParentExecutionId);
    }

    [Fact]
    public void HiLoAlarm_EnteringABand_ParentsOnTriggerRunToTheWritingExecution()
    {
        var writerExecutionId = Guid.NewGuid();
        var alarm = BuildAlarm(
            new ResolvedAlarm
            {
                CanonicalName = "TempBand",
                TriggerType = "HiLo",
                TriggerConfiguration = "{\"attributeName\":\"Temp\",\"hi\":80,\"hiHi\":95}",
                PriorityLevel = 1
            },
            out var instanceProbe);

        alarm.Tell(new AttributeValueChanged(
            "Pump1", "Temp", "Temp", 90.0, "Good", DateTimeOffset.UtcNow,
            SourceExecutionId: writerExecutionId));

        instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
        Assert.Equal(writerExecutionId, alarm.UnderlyingActor.LastOnTriggerParentExecutionId);
    }

    [Fact]
    public void ExpressionAlarm_CarriesTheWriterCapturedWithTheEvaluatedSnapshot()
    {
        // Expression triggers evaluate a whole snapshot OFF the dispatcher, so the
        // firing change is no longer in scope when the boolean comes back. The
        // writer is captured alongside the snapshot and echoed home on the result.
        var writerExecutionId = Guid.NewGuid();
        var alarm = BuildAlarm(
            new ResolvedAlarm
            {
                CanonicalName = "ExprAlarm",
                TriggerType = "Expression",
                TriggerConfiguration = "{\"expression\":\"true\"}",
                PriorityLevel = 1
            },
            out _,
            CompileTriggerExpression("true"));

        alarm.Tell(new AttributeValueChanged(
            "Pump1", "A", "A", 1, "Good", DateTimeOffset.UtcNow,
            SourceExecutionId: writerExecutionId));

        AwaitAssert(
            () => Assert.Equal(writerExecutionId, alarm.UnderlyingActor.LastOnTriggerParentExecutionId),
            TimeSpan.FromSeconds(10));
    }

    // -------------------------------------------------------------------------
    // Hop 1 — ScriptRuntimeContext.SetAttribute stamps the running execution
    // -------------------------------------------------------------------------

    [Fact]
    public async Task SetAttribute_StampsTheRunningExecutionIdOnTheWriteCommand()
    {
        var probe = CreateTestProbe();
        var executionId = Guid.NewGuid();
        var context = new ScriptRuntimeContext(
            probe.Ref,
            _sharedLibrary,
            currentCallDepth: 0,
            maxCallDepth: 10,
            askTimeout: TimeSpan.FromSeconds(5),
            instanceName: "Pump1",
            logger: NullLogger.Instance,
            executionId: executionId);

        var write = context.SetAttribute("Status", "Critical");

        var command = probe.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
        Assert.Equal(executionId, command.SourceExecutionId);

        probe.Reply(new SetStaticAttributeResponse(
            command.CorrelationId, "Pump1", "Status", true, null, DateTimeOffset.UtcNow));
        await write;
    }

    // -------------------------------------------------------------------------
    // Hop 2 — Instance Actor carries the writer onto the published change
    // -------------------------------------------------------------------------

    [Fact]
    public void InstanceActor_StaticWrite_PublishesTheWritingExecutionOnTheChange()
    {
        var streamManager = new SiteStreamManager(
            new SiteRuntimeOptions { StreamBufferSize = 100 },
            NullLogger<SiteStreamManager>.Instance);
        streamManager.Initialize(Sys);

        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Pump1",
            Attributes =
            [
                new ResolvedAttribute { CanonicalName = "Status", Value = "Normal", DataType = "String" }
            ]
        };

        var instance = ActorOf(Props.Create(() => new InstanceActor(
            "Pump1",
            JsonSerializer.Serialize(config),
            _storage,
            _compilationService,
            _sharedLibrary,
            streamManager,
            _options,
            NullLogger<InstanceActor>.Instance)));

        var subscriber = CreateTestProbe();
        streamManager.Subscribe("Pump1", subscriber.Ref);

        var writerExecutionId = Guid.NewGuid();
        instance.Tell(new SetStaticAttributeCommand(
            "corr-1", "Pump1", "Status", "Critical", DateTimeOffset.UtcNow,
            SourceExecutionId: writerExecutionId));

        var published = subscriber.FishForMessage<AttributeValueChanged>(
            m => m.AttributeName == "Status", TimeSpan.FromSeconds(10));
        Assert.Equal(writerExecutionId, published.SourceExecutionId);
    }

    [Fact]
    public void InstanceActor_WriteWithNoAuditedOrigin_PublishesNoExecutionId()
    {
        var streamManager = new SiteStreamManager(
            new SiteRuntimeOptions { StreamBufferSize = 100 },
            NullLogger<SiteStreamManager>.Instance);
        streamManager.Initialize(Sys);

        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Pump2",
            Attributes =
            [
                new ResolvedAttribute { CanonicalName = "Status", Value = "Normal", DataType = "String" }
            ]
        };

        var instance = ActorOf(Props.Create(() => new InstanceActor(
            "Pump2",
            JsonSerializer.Serialize(config),
            _storage,
            _compilationService,
            _sharedLibrary,
            streamManager,
            _options,
            NullLogger<InstanceActor>.Instance)));

        var subscriber = CreateTestProbe();
        streamManager.Subscribe("Pump2", subscriber.Ref);

        instance.Tell(new SetStaticAttributeCommand(
            "corr-2", "Pump2", "Status", "Critical", DateTimeOffset.UtcNow));

        var published = subscriber.FishForMessage<AttributeValueChanged>(
            m => m.AttributeName == "Status", TimeSpan.FromSeconds(10));
        Assert.Null(published.SourceExecutionId);
    }
}
