using System.Collections.Concurrent;
using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;

namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.VirtualTags;

/// <summary>
/// Verifies <see cref="VirtualTagHostActor"/> reconciles a desired set of
/// <see cref="EquipmentVirtualTagPlan"/> into child <see cref="VirtualTagActor"/>s and bridges each
/// child's <see cref="VirtualTagActor.EvaluationResult"/> onto an
/// <see cref="OpcUaPublishActor.AttributeValueUpdate"/> carrying the folder-scoped NodeId computed by
/// the materialiser.
/// </summary>
public sealed class VirtualTagHostActorTests : RuntimeActorTestBase
{
    /// <summary>A plan with no FolderPath maps onto NodeId "EquipmentId/Name".</summary>
    private static EquipmentVirtualTagPlan Plan(
        string vtagId, string equipmentId, string name, string folderPath = "") =>
        new(
            VirtualTagId: vtagId,
            EquipmentId: equipmentId,
            FolderPath: folderPath,
            Name: name,
            DataType: "Double",
            Expression: "ctx.GetTag(\"a\")",
            DependencyRefs: new[] { "a" });

    /// <summary>Spawn: an apply with one plan spins up exactly one live child VirtualTagActor.</summary>
    [Fact]
    public void ApplyVirtualTags_spawns_one_child_per_plan()
    {
        var publish = CreateTestProbe();
        var mux = CreateTestProbe();
        var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux.Ref, new StubEvaluator()));

        host.Tell(new VirtualTagHostActor.ApplyVirtualTags(new[] { Plan("vt-1", "eq-1", "speed-rpm") }));

        // The child self-registers with the mux in PreStart, so a RegisterInterest landing on the
        // mux probe is proof the host spawned a live child.
        var reg = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>();
        reg.TagRefs.ShouldContain("a");
    }

    /// <summary>KEY TEST: a child EvaluationResult is bridged to the publish actor with the
    /// folder-scoped NodeId, Value, Good quality, and source timestamp preserved.</summary>
    [Fact]
    public void EvaluationResult_is_bridged_with_folder_scoped_NodeId()
    {
        var publish = CreateTestProbe();
        var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux: null, new StubEvaluator()));

        host.Tell(new VirtualTagHostActor.ApplyVirtualTags(new[] { Plan("vt-1", "eq-1", "speed-rpm") }));

        var ts = new DateTime(2026, 6, 7, 12, 0, 0, DateTimeKind.Utc);
        host.Tell(new VirtualTagActor.EvaluationResult("vt-1", 42.0, ts, CorrelationId.NewId()));

        var update = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>();
        update.NodeId.ShouldBe("eq-1/speed-rpm");
        update.Value.ShouldBe(42.0);
        update.Quality.ShouldBe(OpcUaQuality.Good);
        update.TimestampUtc.ShouldBe(ts);
    }

    /// <summary>FolderPath is honoured in the published NodeId (EquipmentId/FolderPath/Name).</summary>
    [Fact]
    public void EvaluationResult_NodeId_includes_folder_path_when_set()
    {
        var publish = CreateTestProbe();
        var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux: null, new StubEvaluator()));

        host.Tell(new VirtualTagHostActor.ApplyVirtualTags(
            new[] { Plan("vt-1", "eq-1", "speed-rpm", folderPath: "metrics") }));

        host.Tell(new VirtualTagActor.EvaluationResult("vt-1", 1.0, DateTime.UtcNow, CorrelationId.NewId()));

        var update = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>();
        update.NodeId.ShouldBe("eq-1/metrics/speed-rpm");
    }

    /// <summary>Stop-removed: a vtag dropped from the desired set is unmapped, so a later result for
    /// it produces NO publish.</summary>
    [Fact]
    public void Removed_vtag_is_no_longer_bridged()
    {
        var publish = CreateTestProbe();
        var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux: null, new StubEvaluator()));

        host.Tell(new VirtualTagHostActor.ApplyVirtualTags(new[] { Plan("vt-1", "eq-1", "speed-rpm") }));
        // Re-apply without vt-1 — it should be stopped + unmapped.
        host.Tell(new VirtualTagHostActor.ApplyVirtualTags(Array.Empty<EquipmentVirtualTagPlan>()));

        host.Tell(new VirtualTagActor.EvaluationResult("vt-1", 99.0, DateTime.UtcNow, CorrelationId.NewId()));

        publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
    }

    /// <summary>Unmapped result dropped: a result for an unknown vtagId is silently ignored.</summary>
    [Fact]
    public void Result_for_unknown_vtag_is_dropped()
    {
        var publish = CreateTestProbe();
        var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux: null, new StubEvaluator()));

        host.Tell(new VirtualTagHostActor.ApplyVirtualTags(new[] { Plan("vt-1", "eq-1", "speed-rpm") }));

        host.Tell(new VirtualTagActor.EvaluationResult("vt-unknown", 7.0, DateTime.UtcNow, CorrelationId.NewId()));

        publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
    }

    /// <summary>
    /// After a child actor terminates unexpectedly, a subsequent ApplyVirtualTags (still containing
    /// that vtag) must re-spawn it. Proof: two distinct RegisterInterest messages arrive at the mux
    /// probe — one for the original child and one for the replacement.
    /// </summary>
    [Fact]
    public void Child_is_respawned_after_unexpected_termination()
    {
        var publish = CreateTestProbe();
        var mux = CreateTestProbe();
        var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux.Ref, new StubEvaluator()));
        var plan = new[] { Plan("vt-1", "eq-1", "speed-rpm") };

        // First apply — child self-registers; capture the child ref from the message sender.
        host.Tell(new VirtualTagHostActor.ApplyVirtualTags(plan));
        mux.ExpectMsg<DependencyMuxActor.RegisterInterest>();
        var firstChild = mux.LastSender;

        // Watch the child from the test side so we can await its death deterministically before
        // re-applying, avoiding any race between Terminated delivery to the host and the re-apply.
        Watch(firstChild);
        Sys.Stop(firstChild);
        ExpectTerminated(firstChild);

        // The dying child's PostStop sends UnregisterInterest to the mux — drain it so the mux probe
        // mailbox is clean before we look for the new RegisterInterest.
        mux.ExpectMsg<DependencyMuxActor.UnregisterInterest>(TimeSpan.FromSeconds(5));

        // Re-apply with the same plan — host should see vt-1 absent from _children and spawn fresh.
        host.Tell(new VirtualTagHostActor.ApplyVirtualTags(plan));
        var reg2 = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(TimeSpan.FromSeconds(5));
        reg2.TagRefs.ShouldContain("a");

        // The new child must be a different actor ref than the one we killed.
        var secondChild = mux.LastSender;
        secondChild.ShouldNotBe(firstChild);
    }

    /// <summary>A plan with an explicit Historize flag, so H5c can assert the host historizes a
    /// result iff the plan opted in. Mirrors <see cref="Plan"/> but threads <paramref name="historize"/>.</summary>
    private static EquipmentVirtualTagPlan PlanH(
        string vtagId, string equipmentId, string name, bool historize, string folderPath = "") =>
        new(
            VirtualTagId: vtagId,
            EquipmentId: equipmentId,
            FolderPath: folderPath,
            Name: name,
            DataType: "Double",
            Expression: "ctx.GetTag(\"a\")",
            DependencyRefs: new[] { "a" },
            Historize: historize);

    /// <summary>H5c: a result for a vtag whose plan has Historize=true is recorded with the
    /// IHistoryWriter under the SAME folder-scoped NodeId the value was published to, carrying the
    /// result value and OPC UA Good (0u) status — in addition to the normal publish.</summary>
    [Fact]
    public void Historized_vtag_result_is_recorded_with_the_history_writer()
    {
        var publish = CreateTestProbe();
        var writer = new CapturingHistoryWriter();
        var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux: null, new StubEvaluator(), writer));

        host.Tell(new VirtualTagHostActor.ApplyVirtualTags(
            new[] { PlanH("vt-1", "eq-1", "speed-rpm", historize: true) }));

        var ts = new DateTime(2026, 6, 7, 12, 0, 0, DateTimeKind.Utc);
        host.Tell(new VirtualTagActor.EvaluationResult("vt-1", 42.0, ts, CorrelationId.NewId()));

        // The publish still happens — historization is additive, not a replacement.
        var update = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>();
        update.NodeId.ShouldBe("eq-1/speed-rpm");

        // Wait for the history record to land (delivered on the same actor turn as the publish).
        AwaitAssert(() => writer.Calls.Count.ShouldBe(1));
        writer.Calls.TryPeek(out var captured).ShouldBeTrue();
        var (path, value) = captured;
        path.ShouldBe("eq-1/speed-rpm");
        value.Value.ShouldBe(42.0);
        value.StatusCode.ShouldBe(0u); // OPC UA Good
        value.SourceTimestampUtc.ShouldBe(ts);
        value.ServerTimestampUtc.ShouldBe(ts);
    }

    /// <summary>H5c: a result for a vtag whose plan has Historize=false is NOT recorded — the writer
    /// is never called — though the value is still published.</summary>
    [Fact]
    public void Non_historized_vtag_result_is_not_recorded()
    {
        var publish = CreateTestProbe();
        var writer = new CapturingHistoryWriter();
        var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux: null, new StubEvaluator(), writer));

        host.Tell(new VirtualTagHostActor.ApplyVirtualTags(
            new[] { PlanH("vt-1", "eq-1", "speed-rpm", historize: false) }));

        host.Tell(new VirtualTagActor.EvaluationResult("vt-1", 42.0, DateTime.UtcNow, CorrelationId.NewId()));

        // The value is still published — and the publish completing means OnResult's turn is done,
        // so the historian (which would have been called on the same turn) was never touched.
        publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>();
        writer.Calls.IsEmpty.ShouldBeTrue();
    }

    /// <summary>A plan with an explicit Expression + DependencyRefs (the H1b in-place-change case).</summary>
    private static EquipmentVirtualTagPlan PlanWithRefs(
        string vtagId, string equipmentId, string name, string expression, params string[] refs) =>
        new(
            VirtualTagId: vtagId,
            EquipmentId: equipmentId,
            FolderPath: "",
            Name: name,
            DataType: "Double",
            Expression: expression,
            DependencyRefs: refs);

    /// <summary>
    /// H1b respawn-on-change: re-applying the SAME vtagId with a changed Expression/DependencyRefs
    /// must stop the old child (PostStop ⇒ UnregisterInterest on the old refs) and spawn a fresh one
    /// (PreStart ⇒ RegisterInterest on the new refs). Proof: after the second apply the mux probe sees
    /// an UnregisterInterest and a RegisterInterest carrying the NEW ref "B".
    /// </summary>
    [Fact]
    public void ApplyVirtualTags_respawns_child_when_plan_changes_in_place()
    {
        var publish = CreateTestProbe();
        var mux = CreateTestProbe();
        var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux.Ref, new StubEvaluator()));

        // First apply — child self-registers interest in "A".
        host.Tell(new VirtualTagHostActor.ApplyVirtualTags(
            new[] { PlanWithRefs("vt-1", "eq-1", "speed-rpm", "ctx.GetTag(\"A\")", "A") }));
        var reg1 = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>();
        reg1.TagRefs.ShouldContain("A");
        var firstChild = mux.LastSender;

        // Re-apply the SAME vtagId with a changed Expression + DependencyRefs.
        host.Tell(new VirtualTagHostActor.ApplyVirtualTags(
            new[] { PlanWithRefs("vt-1", "eq-1", "speed-rpm", "ctx.GetTag(\"B\")", "B") }));

        // The old child is stopped (PostStop ⇒ UnregisterInterest) and a new one spawned
        // (PreStart ⇒ RegisterInterest on "B"). Both messages arrive at the mux probe; order between
        // the dying child's PostStop and the new child's PreStart is not guaranteed, so accept either.
        //
        // Because the order is genuinely undefined, the SENDER of the RegisterInterest must be captured
        // as that message is received. Reading mux.LastSender after the loop instead would read the
        // sender of whichever message happened to arrive SECOND — the dying child when the interleaving
        // is [Register, Unregister] — so the identity assertion below silently depended on the very
        // ordering this loop exists to tolerate. That made it fail roughly 10% of the time under a
        // fully parallel assembly run, and never in isolation.
        DependencyMuxActor.RegisterInterest? reg2 = null;
        IActorRef? secondChild = null;
        var sawUnregister = false;
        for (var i = 0; i < 2; i++)
        {
            var msg = mux.ReceiveOne(TimeSpan.FromSeconds(5));
            switch (msg)
            {
                case DependencyMuxActor.RegisterInterest r:
                    reg2 = r;
                    secondChild = mux.LastSender;   // sender OF THIS message, not of the last one seen
                    break;
                case DependencyMuxActor.UnregisterInterest:
                    sawUnregister = true;
                    break;
            }
        }

        sawUnregister.ShouldBeTrue("old child's PostStop should have unregistered its interest");
        reg2.ShouldNotBeNull();
        reg2!.TagRefs.ShouldContain("B");
        reg2.TagRefs.ShouldNotContain("A");

        // The replacement is a different actor ref than the original (auto-named, so no collision).
        secondChild.ShouldNotBeNull();
        secondChild.ShouldNotBe(firstChild);
    }

    /// <summary>
    /// H1b no-churn: re-applying an IDENTICAL plan must NOT respawn the child — the plan's value
    /// equality diffs empty, so no second Unregister/Register pair hits the mux.
    /// </summary>
    [Fact]
    public void ApplyVirtualTags_does_not_respawn_child_when_plan_unchanged()
    {
        var publish = CreateTestProbe();
        var mux = CreateTestProbe();
        var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux.Ref, new StubEvaluator()));

        var plan = new[] { PlanWithRefs("vt-1", "eq-1", "speed-rpm", "ctx.GetTag(\"A\")", "A") };

        // First apply — exactly one RegisterInterest.
        host.Tell(new VirtualTagHostActor.ApplyVirtualTags(plan));
        var reg = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>();
        reg.TagRefs.ShouldContain("A");

        // Re-apply an identical plan (fresh list instances, but value-equal) — no churn expected.
        host.Tell(new VirtualTagHostActor.ApplyVirtualTags(
            new[] { PlanWithRefs("vt-1", "eq-1", "speed-rpm", "ctx.GetTag(\"A\")", "A") }));

        // No second Register and no Unregister: the child was left in place.
        mux.ExpectNoMsg(TimeSpan.FromMilliseconds(400));
    }

    /// <summary>
    /// H1b crash-then-change: after a child terminates unexpectedly AND the next apply carries a
    /// CHANGED plan for the same vtagId, the replacement child must adopt the NEW plan (refs "B"),
    /// not the stale one (refs "A"). This pins that OnChildTerminated prunes _planByVtag so the
    /// change-detect guard can't be confused by a dead child's old plan.
    /// </summary>
    [Fact]
    public void Respawn_after_termination_with_a_changed_plan_adopts_the_new_refs()
    {
        var publish = CreateTestProbe();
        var mux = CreateTestProbe();
        var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux.Ref, new StubEvaluator()));

        // First apply — child registers interest in "A".
        host.Tell(new VirtualTagHostActor.ApplyVirtualTags(
            new[] { PlanWithRefs("vt-1", "eq-1", "speed-rpm", "ctx.GetTag(\"A\")", "A") }));
        mux.ExpectMsg<DependencyMuxActor.RegisterInterest>().TagRefs.ShouldContain("A");
        var firstChild = mux.LastSender;

        // Kill the child deterministically and drain its PostStop UnregisterInterest.
        Watch(firstChild);
        Sys.Stop(firstChild);
        ExpectTerminated(firstChild);
        mux.ExpectMsg<DependencyMuxActor.UnregisterInterest>(TimeSpan.FromSeconds(5));

        // Re-apply with a CHANGED plan (refs "B"). The replacement must register the NEW refs.
        host.Tell(new VirtualTagHostActor.ApplyVirtualTags(
            new[] { PlanWithRefs("vt-1", "eq-1", "speed-rpm", "ctx.GetTag(\"B\")", "B") }));
        var reg2 = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(TimeSpan.FromSeconds(5));
        reg2.TagRefs.ShouldContain("B");
        reg2.TagRefs.ShouldNotContain("A");
        mux.LastSender.ShouldNotBe(firstChild);
    }

    /// <summary>Wiring guard (arch-review 02/U3 + 02/P7, theme #1): the host actor calls
    /// <see cref="IScriptCacheOwner.ClearCompiledScripts"/> at the apply boundary — but, per P7, ONLY
    /// when the deployed expression set actually changed. The clear stays the load-bearing wire (the
    /// production evaluator roots one collectible ALC per compiled script), yet a byte-identical
    /// redeploy — or a rename / add / remove that leaves the expression set unchanged — no longer forces
    /// a full recompilation. This pins that the host makes the call, and gates it correctly.
    ///
    /// <para>The final <c>ClearCount</c> is read at a deterministic sync point: after all applies are
    /// queued we drive one EvaluationResult through the actor and wait for the bridged publish — the
    /// FIFO mailbox guarantees every prior apply is processed by then, so the counter is final (a
    /// polling AwaitAssert on a monotonic counter would pass transiently as it climbs, hiding the
    /// unconditional-clear bug).</para></summary>
    [Fact]
    public void ApplyVirtualTags_clears_the_compile_cache_only_when_the_expression_set_changes()
    {
        var publish = CreateTestProbe();
        var evaluator = new CacheOwningStubEvaluator();
        var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux: null, evaluator));

        // 1) First generation: previous set {} → {E1} differs → clears.
        host.Tell(new VirtualTagHostActor.ApplyVirtualTags(
            new[] { PlanWithRefs("vt-1", "eq-1", "speed-rpm", "E1", "a") }));
        // 2) Byte-identical redeploy: {E1} → {E1} → NO clear (the P7 win).
        host.Tell(new VirtualTagHostActor.ApplyVirtualTags(
            new[] { PlanWithRefs("vt-1", "eq-1", "speed-rpm", "E1", "a") }));
        // 3) Edited expression: {E1} → {E2} differs → clears.
        host.Tell(new VirtualTagHostActor.ApplyVirtualTags(
            new[] { PlanWithRefs("vt-1", "eq-1", "speed-rpm", "E2", "a") }));
        // 4) Add a second vtag SHARING the same expression E2: {E2} → {E2} → NO clear.
        host.Tell(new VirtualTagHostActor.ApplyVirtualTags(new[]
        {
            PlanWithRefs("vt-1", "eq-1", "speed-rpm", "E2", "a"),
            PlanWithRefs("vt-2", "eq-1", "torque", "E2", "a"),
        }));
        // 5) Remove that second vtag — expression E2 still deployed by vt-1: {E2} → {E2} → NO clear.
        host.Tell(new VirtualTagHostActor.ApplyVirtualTags(
            new[] { PlanWithRefs("vt-1", "eq-1", "speed-rpm", "E2", "a") }));

        // Sync point: a result for the still-mapped vt-1 bridges to a publish; when it lands, every
        // apply above has been processed.
        host.Tell(new VirtualTagActor.EvaluationResult("vt-1", 1.0, DateTime.UtcNow, CorrelationId.NewId()));
        publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>();

        // Exactly two clears: the first generation and the E1→E2 edit. The identical redeploy and the
        // shared-expression add/remove did not clear.
        evaluator.ClearCount.ShouldBe(2);
    }

    /// <summary>
    /// 02/S13 stale-Good repro (RED on current code): a script that succeeds once then starts failing
    /// must degrade its node's quality to Bad — carrying the last-known value — rather than freezing on
    /// the last Good value forever. Drives the REAL host + real child + parent-Tell bridge: apply one
    /// plan (deps ["a"]), capture the spawned child via the mux probe's RegisterInterest sender, then
    /// feed it two dependency changes. The first evaluates Ok(42) → a Good AttributeValueUpdate; the
    /// second fails → the fix publishes a Bad AttributeValueUpdate carrying the stale 42. On the
    /// unfixed tree the second publish never arrives (the second ExpectMsg times out).
    /// </summary>
    [Fact]
    public void Failing_script_after_success_degrades_node_quality_to_Bad()
    {
        var publish = CreateTestProbe();
        var mux = CreateTestProbe();
        var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux.Ref,
            new FailAfterFirstSuccessEvaluator()));

        host.Tell(new VirtualTagHostActor.ApplyVirtualTags(new[] { Plan("vt-1", "eq-1", "speed-rpm") }));

        // The child self-registers with the mux in PreStart — capture its ref from the sender so we can
        // drive it directly through the real evaluate → parent-Tell → bridge path.
        mux.ExpectMsg<DependencyMuxActor.RegisterInterest>();
        var child = mux.LastSender;

        var ts1 = new DateTime(2026, 7, 12, 12, 0, 0, DateTimeKind.Utc);
        var ts2 = new DateTime(2026, 7, 12, 12, 0, 1, DateTimeKind.Utc);

        // First dep change → evaluator Ok(42) → Good publish.
        child.Tell(new VirtualTagActor.DependencyValueChanged("a", 1, ts1));
        var good = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>();
        good.Quality.ShouldBe(OpcUaQuality.Good);
        good.Value.ShouldBe(42.0);

        // Second dep change → evaluator Failure → Bad publish carrying the last-known value 42.
        child.Tell(new VirtualTagActor.DependencyValueChanged("a", 2, ts2));
        var bad = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>();
        bad.Quality.ShouldBe(OpcUaQuality.Bad);
        bad.Value.ShouldBe(42.0);
        bad.TimestampUtc.ShouldBe(ts2);
    }

    /// <summary>02/S13 bridge: a Bad-quality <see cref="VirtualTagActor.EvaluationResult"/> must bridge to
    /// an <see cref="OpcUaPublishActor.AttributeValueUpdate"/> carrying <see cref="OpcUaQuality.Bad"/>
    /// (not the old hard-coded Good).</summary>
    [Fact]
    public void Bad_quality_EvaluationResult_is_bridged_as_Bad()
    {
        var publish = CreateTestProbe();
        var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux: null, new StubEvaluator()));

        host.Tell(new VirtualTagHostActor.ApplyVirtualTags(new[] { Plan("vt-1", "eq-1", "speed-rpm") }));

        var ts = new DateTime(2026, 7, 12, 12, 0, 0, DateTimeKind.Utc);
        host.Tell(new VirtualTagActor.EvaluationResult(
            "vt-1", 42.0, ts, CorrelationId.NewId(), OpcUaQuality.Bad));

        var update = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>();
        update.Quality.ShouldBe(OpcUaQuality.Bad);
        update.Value.ShouldBe(42.0);
    }

    /// <summary>02/S13 historize parity: a Bad result on a historized plan must record the dormant
    /// engine's BadInternalError status (0x80020000u) in the snapshot, not the Good 0u.</summary>
    [Fact]
    public void Historized_Bad_result_is_recorded_with_BadInternalError_status()
    {
        var publish = CreateTestProbe();
        var writer = new CapturingHistoryWriter();
        var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux: null, new StubEvaluator(), writer));

        host.Tell(new VirtualTagHostActor.ApplyVirtualTags(
            new[] { PlanH("vt-1", "eq-1", "speed-rpm", historize: true) }));

        var ts = new DateTime(2026, 7, 12, 12, 0, 0, DateTimeKind.Utc);
        host.Tell(new VirtualTagActor.EvaluationResult(
            "vt-1", 42.0, ts, CorrelationId.NewId(), OpcUaQuality.Bad));

        publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>();
        AwaitAssert(() => writer.Calls.Count.ShouldBe(1));
        writer.Calls.TryPeek(out var captured).ShouldBeTrue();
        captured.Value.StatusCode.ShouldBe(0x80020000u); // BadInternalError — dormant-engine parity
    }

    /// <summary>
    /// v3 Batch 4 live-gate regression (RED on the pre-fix tree): after a deploy re-materialises a
    /// VirtualTag's UNS node to BadWaitingForInitialData, a surviving (unchanged-plan) child must
    /// re-assert its last value so the node recovers — otherwise a static-dependency VirtualTag stays Bad
    /// forever because its unchanged recompute is dedup-suppressed. Drives the REAL host + real child:
    /// apply a plan, capture the spawned child via the mux probe, feed one dependency change to publish a
    /// Good value (which the child then dedups), then re-apply the SAME plan (the redeploy). The fix makes
    /// the survivor re-publish its last value; on the unfixed tree the second ExpectMsg times out.
    /// </summary>
    [Fact]
    public void Unchanged_redeploy_reasserts_last_value_so_a_reset_uns_node_recovers()
    {
        var publish = CreateTestProbe();
        var mux = CreateTestProbe();
        var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux.Ref, new ConstOkEvaluator(7.0)));

        var plan = new[] { Plan("vt-1", "eq-1", "speed-rpm") };
        host.Tell(new VirtualTagHostActor.ApplyVirtualTags(plan));

        // Capture the spawned child from the RegisterInterest sender so we can drive a dependency change
        // through the real evaluate → parent-Tell → bridge path.
        mux.ExpectMsg<DependencyMuxActor.RegisterInterest>();
        var child = mux.LastSender;

        // First dependency change → Ok(7) → Good publish; the child now holds 7 as its dedup'd last value.
        child.Tell(new VirtualTagActor.DependencyValueChanged("a", 1, DateTime.UtcNow));
        var first = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>();
        first.Value.ShouldBe(7.0);
        first.Quality.ShouldBe(OpcUaQuality.Good);

        // The redeploy re-materialises the node to BadWaitingForInitialData and re-applies the UNCHANGED
        // plan — the child survives with its dedup state. The fix re-asserts the last value; without it the
        // node would stay Bad (an unchanged recompute of 7 is dedup-suppressed) and this ExpectMsg times out.
        host.Tell(new VirtualTagHostActor.ApplyVirtualTags(plan));
        var reasserted = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>();
        reasserted.NodeId.ShouldBe("eq-1/speed-rpm");
        reasserted.Value.ShouldBe(7.0);
        reasserted.Quality.ShouldBe(OpcUaQuality.Good);

        // No child churn on the unchanged redeploy — the re-assert must not respawn or re-register.
        mux.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
    }

    /// <summary>
    /// M1 (reassert review): a re-assert must PUBLISH (to repair the reset OPC UA node) but must NOT
    /// re-record to the historian. Otherwise every deploy appends an artificial historian sample carrying a
    /// stale value with a fresh deploy-time timestamp — a data-quality bug the moment a VT history sink is
    /// bound. Historize=true survivor: first real evaluation records exactly once; the redeploy's re-assert
    /// publishes a second time but the historian count stays 1.
    /// </summary>
    [Fact]
    public void Reassert_of_a_historized_vtag_publishes_but_does_not_re_record_to_the_historian()
    {
        var publish = CreateTestProbe();
        var mux = CreateTestProbe();
        var writer = new CapturingHistoryWriter();
        var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux.Ref, new ConstOkEvaluator(7.0), writer));

        var plan = new[] { PlanH("vt-1", "eq-1", "speed-rpm", historize: true) };
        host.Tell(new VirtualTagHostActor.ApplyVirtualTags(plan));

        mux.ExpectMsg<DependencyMuxActor.RegisterInterest>();
        var child = mux.LastSender;

        // First real evaluation → Good publish + exactly one historian record.
        child.Tell(new VirtualTagActor.DependencyValueChanged("a", 1, DateTime.UtcNow));
        publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>();
        AwaitAssert(() => writer.Calls.Count.ShouldBe(1));

        // Redeploy (unchanged plan) → the survivor re-asserts: a SECOND publish repairs the reset node.
        host.Tell(new VirtualTagHostActor.ApplyVirtualTags(plan));
        publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>();

        // The publish landing means the reassert's OnResult turn is complete (Record runs on the same turn,
        // right after the Tell) — so a second Record would already be visible. It must NOT be: still 1.
        writer.Calls.Count.ShouldBe(1);
    }

    /// <summary>Evaluator that always returns <c>Ok(value)</c> — a static-output VirtualTag whose recompute
    /// is dedup-suppressed after the first publish, so the re-assert path is the only way its reset node
    /// recovers.</summary>
    private sealed class ConstOkEvaluator : IVirtualTagEvaluator
    {
        private readonly object _value;

        /// <summary>Creates an evaluator that always returns <paramref name="value"/>.</summary>
        /// <param name="value">The constant value to return.</param>
        public ConstOkEvaluator(object value) => _value = value;

        /// <summary>Returns <c>Ok(value)</c> regardless of inputs.</summary>
        /// <param name="id">The tag identifier.</param>
        /// <param name="expr">The expression string.</param>
        /// <param name="deps">The dependency values.</param>
        /// <returns>A successful result carrying the fixed value.</returns>
        public VirtualTagEvalResult Evaluate(string id, string expr, IReadOnlyDictionary<string, object?> deps)
            => VirtualTagEvalResult.Ok(_value);
    }

    /// <summary>Evaluator that returns <c>Ok(42.0)</c> on its first call and <c>Failure</c> thereafter —
    /// the S13 "was working, now broken" shape. Interlocked counter so it is safe across the actor's
    /// evaluation thread.</summary>
    private sealed class FailAfterFirstSuccessEvaluator : IVirtualTagEvaluator
    {
        private int _calls;

        /// <summary>Returns Ok(42.0) once, then Failure("boom").</summary>
        /// <param name="id">The tag identifier.</param>
        /// <param name="expr">The expression string.</param>
        /// <param name="deps">The dependency values.</param>
        /// <returns>Ok(42.0) on the first call; Failure on every subsequent call.</returns>
        public VirtualTagEvalResult Evaluate(string id, string expr, IReadOnlyDictionary<string, object?> deps)
            => Interlocked.Increment(ref _calls) == 1
                ? VirtualTagEvalResult.Ok(42.0)
                : VirtualTagEvalResult.Failure("boom");
    }

    /// <summary>Capturing <see cref="IHistoryWriter"/>: records every Record call so H5c tests can
    /// assert the host historizes (or does not) and with what path + snapshot.</summary>
    private sealed class CapturingHistoryWriter : IHistoryWriter
    {
        // ConcurrentQueue: Record runs on the actor thread, the test asserts on the test thread —
        // a plain List would be a cross-thread data race under load.
        public readonly ConcurrentQueue<(string Path, DataValueSnapshot Value)> Calls = new();

        /// <summary>Captures the path + snapshot of a Record call.</summary>
        /// <param name="path">The virtual tag path.</param>
        /// <param name="value">The data value snapshot.</param>
        public void Record(string path, DataValueSnapshot value) => Calls.Enqueue((path, value));
    }

    /// <summary>Deterministic no-op evaluator: keeps spawned children inert so tests drive the host's
    /// OnResult path directly via synthetic EvaluationResults.</summary>
    private sealed class StubEvaluator : IVirtualTagEvaluator
    {
        /// <summary>Returns NoChange so the child never emits on its own.</summary>
        /// <param name="id">The tag identifier.</param>
        /// <param name="expr">The expression string.</param>
        /// <param name="deps">The dependency values.</param>
        /// <returns>A NoChange result.</returns>
        public VirtualTagEvalResult Evaluate(string id, string expr, IReadOnlyDictionary<string, object?> deps)
            => VirtualTagEvalResult.NoChange;
    }

    /// <summary>Spy evaluator that also owns a script cache: records how many times the host actor
    /// calls <see cref="IScriptCacheOwner.ClearCompiledScripts"/> so the apply-boundary wiring can be
    /// asserted. Counter is interlocked/volatile — written on the actor thread, read on the test thread.</summary>
    private sealed class CacheOwningStubEvaluator : IVirtualTagEvaluator, IScriptCacheOwner
    {
        private int _clearCount;

        /// <summary>Number of ClearCompiledScripts calls observed.</summary>
        public int ClearCount => Volatile.Read(ref _clearCount);

        /// <inheritdoc/>
        public VirtualTagEvalResult Evaluate(string id, string expr, IReadOnlyDictionary<string, object?> deps)
            => VirtualTagEvalResult.NoChange;

        /// <inheritdoc/>
        public void ClearCompiledScripts() => Interlocked.Increment(ref _clearCount);
    }
}
