using Serilog;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;

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

/// <summary>
///     End-to-end VirtualTagEngine behavior: load config, subscribe to upstream,
///     evaluate on change, cascade through dependent virtual tags, timer-driven
///     re-evaluation, error isolation, historize flag, cycle rejection.
/// </summary>
[Trait("Category", "Unit")]
public sealed class VirtualTagEngineTests
{
    private static VirtualTagEngine Build(
        FakeUpstream upstream,
        IHistoryWriter? history = null,
        TimeSpan? scriptTimeout = null,
        Func<DateTime>? clock = null)
    {
        var rootLogger = new LoggerConfiguration().CreateLogger();
        return new VirtualTagEngine(
            upstream,
            new ScriptLoggerFactory(rootLogger),
            rootLogger,
            history,
            clock,
            scriptTimeout);
    }

    /// <summary>Verifies that a simple virtual tag script can read an upstream tag and return a coerced value.</summary>
    [Fact]
    public async Task Simple_script_reads_upstream_and_returns_coerced_value()
    {
        var up = new FakeUpstream();
        up.Set("InTag", 10.0);
        using var engine = Build(up);

        engine.Load([new VirtualTagDefinition(
            Path: "LineRate",
            DataType: DriverDataType.Float64,
            ScriptSource: """return (double)ctx.GetTag("InTag").Value * 2.0;""")]);

        await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);

        var result = engine.Read("LineRate");
        result.StatusCode.ShouldBe(0u);
        result.Value.ShouldBe(20.0);
    }

    /// <summary>Verifies that an upstream tag change triggers a cascade re-evaluation through two levels of dependent virtual tags.</summary>
    [Fact]
    public async Task Upstream_change_triggers_cascade_through_two_levels()
    {
        var up = new FakeUpstream();
        up.Set("A", 1.0);
        using var engine = Build(up);

        engine.Load([
            new VirtualTagDefinition("B", DriverDataType.Float64,
                """return (double)ctx.GetTag("A").Value + 10.0;"""),
            new VirtualTagDefinition("C", DriverDataType.Float64,
                """return (double)ctx.GetTag("B").Value * 2.0;"""),
        ]);

        await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
        engine.Read("B").Value.ShouldBe(11.0);
        engine.Read("C").Value.ShouldBe(22.0);

        // Change upstream — cascade should recompute B (11→15.0) then C (30.0).
        // Both B and C are updated in the same CascadeAsync call (topological order:
        // B then C), but we must wait for each independently: the WaitForConditionAsync
        // on B returns as soon as _valueCache["B"] is set (before the semaphore is
        // released for C's evaluation), so asserting C immediately after the B-wait
        // races against C's still-in-progress evaluation. Wait for C explicitly.
        up.Push("A", 5.0);
        await WaitForConditionAsync(() => Equals(engine.Read("B").Value, 15.0));
        await WaitForConditionAsync(() => Equals(engine.Read("C").Value, 30.0));
        engine.Read("B").Value.ShouldBe(15.0);
        engine.Read("C").Value.ShouldBe(30.0);
    }

    /// <summary>Verifies that a circular dependency among virtual tags is rejected at load time.</summary>
    [Fact]
    public async Task Cycle_in_virtual_tags_rejected_at_Load()
    {
        var up = new FakeUpstream();
        using var engine = Build(up);

        Should.Throw<DependencyCycleException>(() => engine.Load([
            new VirtualTagDefinition("A", DriverDataType.Int32, """return (int)ctx.GetTag("B").Value + 1;"""),
            new VirtualTagDefinition("B", DriverDataType.Int32, """return (int)ctx.GetTag("A").Value + 1;"""),
        ]));
        await Task.CompletedTask;
    }

    /// <summary>Verifies that script compilation errors surface at load time with all failures aggregated.</summary>
    [Fact]
    public async Task Script_compile_error_surfaces_at_Load_with_all_failures()
    {
        var up = new FakeUpstream();
        using var engine = Build(up);

        var ex = Should.Throw<InvalidOperationException>(() => engine.Load([
            new VirtualTagDefinition("A", DriverDataType.Int32, """return undefinedIdentifier;"""),
            new VirtualTagDefinition("B", DriverDataType.Int32, """return 42;"""),
            new VirtualTagDefinition("C", DriverDataType.Int32, """var x = anotherUndefined; return x;"""),
        ]));
        ex.Message.ShouldContain("2 script(s) did not compile");
        ex.Message.ShouldContain("A");
        ex.Message.ShouldContain("C");
        await Task.CompletedTask;
    }

    /// <summary>Verifies that a runtime exception in one virtual tag's script is isolated and does not affect other tags.</summary>
    [Fact]
    public async Task Script_runtime_exception_isolates_to_owning_tag()
    {
        var up = new FakeUpstream();
        up.Set("OK", 10);
        using var engine = Build(up);

        engine.Load([
            new VirtualTagDefinition("GoodTag", DriverDataType.Int32,
                """return (int)ctx.GetTag("OK").Value * 2;"""),
            new VirtualTagDefinition("BadTag", DriverDataType.Int32,
                """throw new InvalidOperationException("boom");"""),
        ]);
        await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);

        engine.Read("GoodTag").StatusCode.ShouldBe(0u);
        engine.Read("GoodTag").Value.ShouldBe(20);
        engine.Read("BadTag").StatusCode.ShouldBe(0x80020000u, "BadInternalError for thrown script");
        engine.Read("BadTag").Value.ShouldBeNull();
    }

    /// <summary>Verifies that script timeout is mapped to BadInternalError status without killing the engine.</summary>
    [Fact]
    public async Task Timeout_maps_to_BadInternalError_without_killing_the_engine()
    {
        var up = new FakeUpstream();
        using var engine = Build(up, scriptTimeout: TimeSpan.FromMilliseconds(30));

        engine.Load([
            new VirtualTagDefinition("Hang", DriverDataType.Int32, """
                var end = DateTime.UtcNow.AddSeconds(5);
                while (DateTime.UtcNow < end) { }
                return 1;
                """),
            new VirtualTagDefinition("Ok", DriverDataType.Int32, """return 42;"""),
        ]);

        await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
        engine.Read("Hang").StatusCode.ShouldBe(0x80020000u);
        engine.Read("Ok").Value.ShouldBe(42);
    }

    /// <summary>Verifies that subscribers receive notifications when the engine emits value changes.</summary>
    [Fact]
    public async Task Subscribers_receive_engine_emitted_changes()
    {
        var up = new FakeUpstream();
        up.Set("In", 1);
        using var engine = Build(up);

        engine.Load([new VirtualTagDefinition(
            "Out", DriverDataType.Int32, """return (int)ctx.GetTag("In").Value + 100;""")]);
        await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);

        var received = new List<DataValueSnapshot>();
        using var sub = engine.Subscribe("Out", (p, v) => received.Add(v));

        up.Push("In", 5);
        await WaitForConditionAsync(() => received.Count >= 1);

        received[^1].Value.ShouldBe(105);
    }

    /// <summary>Verifies that the historize flag routes virtual tag values to the history writer.</summary>
    [Fact]
    public async Task Historize_flag_routes_to_history_writer()
    {
        var recorded = new List<(string, DataValueSnapshot)>();
        var history = new TestHistory(recorded);
        var up = new FakeUpstream();
        up.Set("In", 1);
        using var engine = Build(up, history);

        engine.Load([
            new VirtualTagDefinition("H", DriverDataType.Int32,
                """return (int)ctx.GetTag("In").Value + 1;""", Historize: true),
            new VirtualTagDefinition("NoH", DriverDataType.Int32,
                """return (int)ctx.GetTag("In").Value - 1;""", Historize: false),
        ]);
        await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);

        recorded.Select(p => p.Item1).ShouldContain("H");
        recorded.Select(p => p.Item1).ShouldNotContain("NoH");
    }

    /// <summary>Verifies that upstream pushes are ignored when change-driven is false.</summary>
    [Fact]
    public async Task Change_driven_false_ignores_upstream_push()
    {
        var up = new FakeUpstream();
        up.Set("In", 1);
        using var engine = Build(up);
        engine.Load([new VirtualTagDefinition(
            "Manual", DriverDataType.Int32,
            """return (int)ctx.GetTag("In").Value * 10;""",
            ChangeTriggered: false)]);

        // Initial eval seeds the value.
        await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
        engine.Read("Manual").Value.ShouldBe(10);

        // Upstream change fires but change-driven is off — no recompute.
        up.Push("In", 99);
        await Task.Delay(100);
        engine.Read("Manual").Value.ShouldBe(10, "change-driven=false ignores upstream deltas");
    }

    /// <summary>Verifies that reloading the engine replaces existing tags and resubscribes to upstream sources cleanly.</summary>
    [Fact]
    public async Task Reload_replaces_existing_tags_and_resubscribes_cleanly()
    {
        var up = new FakeUpstream();
        up.Set("A", 1);
        up.Set("B", 2);
        using var engine = Build(up);

        engine.Load([new VirtualTagDefinition(
            "T", DriverDataType.Int32, """return (int)ctx.GetTag("A").Value * 2;""")]);
        await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
        engine.Read("T").Value.ShouldBe(2);
        up.ActiveSubscriptionCount.ShouldBe(1);

        // Reload — T now depends on B instead of A.
        engine.Load([new VirtualTagDefinition(
            "T", DriverDataType.Int32, """return (int)ctx.GetTag("B").Value * 3;""")]);
        await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
        engine.Read("T").Value.ShouldBe(6);
        up.ActiveSubscriptionCount.ShouldBe(1, "previous subscription on A must be disposed");
        await Task.CompletedTask;
    }

    /// <summary>Verifies that disposing the engine releases all upstream subscriptions.</summary>
    [Fact]
    public async Task Dispose_releases_upstream_subscriptions()
    {
        var up = new FakeUpstream();
        up.Set("A", 1);
        var engine = Build(up);
        engine.Load([new VirtualTagDefinition(
            "T", DriverDataType.Int32, """return (int)ctx.GetTag("A").Value;""")]);
        up.ActiveSubscriptionCount.ShouldBe(1);

        engine.Dispose();
        up.ActiveSubscriptionCount.ShouldBe(0);
        await Task.CompletedTask;
    }

    /// <summary>Verifies that SetVirtualTag called within a script updates the target and triggers observers.</summary>
    [Fact]
    public async Task SetVirtualTag_within_script_updates_target_and_triggers_observers()
    {
        var up = new FakeUpstream();
        up.Set("In", 5);
        using var engine = Build(up);

        engine.Load([
            new VirtualTagDefinition("Target", DriverDataType.Int32,
                """return 0;""", ChangeTriggered: false),  // placeholder value, operator-written via SetVirtualTag
            new VirtualTagDefinition("Driver", DriverDataType.Int32,
                """
                var v = (int)ctx.GetTag("In").Value;
                ctx.SetVirtualTag("Target", v * 100);
                return v;
                """),
        ]);
        await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);

        engine.Read("Target").Value.ShouldBe(500);
        engine.Read("Driver").Value.ShouldBe(5);
    }

    /// <summary>Verifies that SetVirtualTag within a script cascades to change-triggered dependents of the written tag.</summary>
    [Fact]
    public async Task SetVirtualTag_within_script_cascades_to_dependents_of_the_written_tag()
    {
        // Regression for Core.VirtualTags-001: a ctx.SetVirtualTag write must schedule
        // the change-trigger cascade for tags depending on the written path, exactly
        // like an upstream delta. "Dependent" reads "Target" and is ChangeTriggered, so
        // a write to Target via ctx.SetVirtualTag must re-evaluate Dependent.
        var up = new FakeUpstream();
        up.Set("In", 5);
        using var engine = Build(up);

        engine.Load([
            new VirtualTagDefinition("Target", DriverDataType.Int32,
                """return 0;""", ChangeTriggered: false),  // operator-written via SetVirtualTag
            new VirtualTagDefinition("Dependent", DriverDataType.Int32,
                """return (int)ctx.GetTag("Target").Value + 1;""", ChangeTriggered: true),
            new VirtualTagDefinition("Writer", DriverDataType.Int32,
                """
                var v = (int)ctx.GetTag("In").Value;
                ctx.SetVirtualTag("Target", v * 100);
                return v;
                """),
        ]);
        await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);

        // Writer set Target = 500; the cascade must have re-evaluated Dependent to 501.
        engine.Read("Target").Value.ShouldBe(500);
        await WaitForConditionAsync(() => Equals(engine.Read("Dependent").Value, 501));
        engine.Read("Dependent").Value.ShouldBe(501,
            "a ctx.SetVirtualTag write must cascade to change-triggered dependents");
    }

    /// <summary>Verifies that type coercion from script double to configured int32 works correctly.</summary>
    [Fact]
    public async Task Type_coercion_from_script_double_to_config_int32()
    {
        var up = new FakeUpstream();
        up.Set("In", 3.7);
        using var engine = Build(up);

        engine.Load([new VirtualTagDefinition(
            "Rounded", DriverDataType.Int32,
            """return (double)ctx.GetTag("In").Value;""")]);
        await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);

        engine.Read("Rounded").Value.ShouldBe(4, "Convert.ToInt32 rounds 3.7 to 4");
    }

    // ----- Core.VirtualTags-012: previously-missing coverage -----

    /// <summary>Verifies that the AreInputsReady guard publishes BadWaitingForInitialData when upstream tags have bad status.</summary>
    [Fact]
    public async Task AreInputsReady_guard_publishes_BadWaitingForInitialData_when_upstream_is_bad()
    {
        // Arrange: upstream tag is Bad-quality (not yet available).
        var up = new FakeUpstream();
        up.Set("BadIn", null!, 0x80000000u); // bad status, null value
        using var engine = Build(up);

        engine.Load([new VirtualTagDefinition(
            "Derived", DriverDataType.Int32,
            """return (int)ctx.GetTag("BadIn").Value * 2;""")]);

        // Act: evaluate — inputs are not ready.
        await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);

        // Assert: tag publishes BadWaitingForInitialData, not a stale null/Good.
        var result = engine.Read("Derived");
        result.StatusCode.ShouldBe(0x80320000u, "BadWaitingForInitialData expected when inputs are bad");
        result.Value.ShouldBeNull();
    }

    /// <summary>Verifies that the AreInputsReady guard recovers when an upstream tag transitions from bad to good status.</summary>
    [Fact]
    public async Task AreInputsReady_guard_publishes_BadWaitingForInitialData_then_recovers_when_upstream_becomes_good()
    {
        // Arrange: upstream tag starts absent (null/Bad).
        var up = new FakeUpstream();
        using var engine = Build(up);

        engine.Load([new VirtualTagDefinition(
            "Derived", DriverDataType.Int32,
            """return (int)ctx.GetTag("In").Value + 1;""")]);

        // First evaluation: upstream not ready → BadWaitingForInitialData.
        await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
        engine.Read("Derived").StatusCode.ShouldBe(0x80320000u);

        // Upstream becomes available.
        up.Push("In", 10);
        await WaitForConditionAsync(() => engine.Read("Derived").StatusCode == 0u);

        // Tag should now have a Good value.
        engine.Read("Derived").StatusCode.ShouldBe(0u);
        engine.Read("Derived").Value.ShouldBe(11);
    }

    /// <summary>Verifies that SetVirtualTag cascades to change-triggered dependents.</summary>
    [Fact]
    public async Task SetVirtualTag_cascades_to_change_triggered_dependent()
    {
        // Arrange: "Writer" writes to "Target"; "Consumer" reads "Target" and is change-triggered.
        var up = new FakeUpstream();
        up.Set("In", 3);
        using var engine = Build(up);

        engine.Load([
            new VirtualTagDefinition("Target", DriverDataType.Int32,
                """return 0;""", ChangeTriggered: false),
            new VirtualTagDefinition("Writer", DriverDataType.Int32,
                """
                var v = (int)ctx.GetTag("In").Value;
                ctx.SetVirtualTag("Target", v * 10);
                return v;
                """),
            new VirtualTagDefinition("Consumer", DriverDataType.Int32,
                """return (int)ctx.GetTag("Target").Value + 1;""",
                ChangeTriggered: true),
        ]);

        await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);

        // Writer sets Target = 30; Consumer should cascade and compute 31.
        await WaitForConditionAsync(() => engine.Read("Consumer").Value is int v && v == 31);
        engine.Read("Target").Value.ShouldBe(30);
        engine.Read("Consumer").Value.ShouldBe(31);
    }

    /// <summary>Verifies that SetVirtualTag calls targeting unregistered paths are caught at load time.</summary>
    [Fact]
    public async Task SetVirtualTag_on_non_registered_path_is_caught_at_Load()
    {
        // Originally validated the runtime warning-and-drop branch in OnScriptSetVirtualTag.
        // After Core.VirtualTags-011 the static DependencyExtractor.Writes set is validated
        // at Load time, so a literal-string write to a non-existent path is now rejected
        // at publish — the dynamic warning path is reserved as a defensive guard for cases
        // the static extractor cannot see (currently none, since dynamic paths are also
        // rejected at extraction).
        var up = new FakeUpstream();
        up.Set("In", 1);
        using var engine = Build(up);

        Should.Throw<InvalidOperationException>(() => engine.Load([
            new VirtualTagDefinition("Writer", DriverDataType.Int32,
                """
                ctx.SetVirtualTag("NonExistentPath", 99);
                return (int)ctx.GetTag("In").Value;
                """)
        ])).Message.ShouldContain("NonExistentPath");
        await Task.CompletedTask;
    }

    /// <summary>Verifies that EvaluateOneAsync throws ArgumentException when called for an unregistered path.</summary>
    [Fact]
    public async Task EvaluateOneAsync_throws_ArgumentException_for_unregistered_path()
    {
        var up = new FakeUpstream();
        using var engine = Build(up);
        engine.Load([new VirtualTagDefinition("A", DriverDataType.Int32, """return 1;""")]);

        await Should.ThrowAsync<ArgumentException>(async () =>
            await engine.EvaluateOneAsync("NoSuchTag", TestContext.Current.CancellationToken));
    }

    /// <summary>Verifies that a type coercion failure maps to BadInternalError status.</summary>
    [Fact]
    public async Task CoerceResult_failure_maps_to_BadInternalError()
    {
        // Arrange: script returns an object that cannot be coerced to the declared type.
        var up = new FakeUpstream();
        using var engine = Build(up);

        engine.Load([new VirtualTagDefinition(
            "Bad", DriverDataType.Int32,
            // Return a non-numeric string — Convert.ToInt32("not-a-number") throws.
            """return "not-a-number";""")]);

        await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);

        // CoerceResult returns null on failure; the null propagates as BadInternalError.
        engine.Read("Bad").StatusCode.ShouldBe(0x80020000u, "type-coercion failure must map to BadInternalError");
        engine.Read("Bad").Value.ShouldBeNull();
    }

    // ----- Core.VirtualTags-011: Writes target validation at Load time -----

    /// <summary>Verifies that Load rejects scripts that write to unregistered virtual tag paths.</summary>
    [Fact]
    public async Task Load_rejects_script_writing_to_unregistered_virtual_tag_path()
    {
        // Regression for Core.VirtualTags-011: a script that calls
        // ctx.SetVirtualTag("Typo", ...) must be caught at publish/load time rather than
        // silently dropped at runtime, so operator typos surface as a publish failure.
        var up = new FakeUpstream();
        using var engine = Build(up);

        var ex = Should.Throw<InvalidOperationException>(() => engine.Load([
            new VirtualTagDefinition("Writer", DriverDataType.Int32,
                """
                ctx.SetVirtualTag("NonRegisteredTarget", 1);
                return 0;
                """),
            new VirtualTagDefinition("RegisteredTarget", DriverDataType.Int32,
                """return 1;"""),
        ]));
        ex.Message.ShouldContain("Writer");
        ex.Message.ShouldContain("NonRegisteredTarget");
        await Task.CompletedTask;
    }

    /// <summary>Verifies that Load accepts scripts that write to registered virtual tag paths.</summary>
    [Fact]
    public async Task Load_accepts_script_writing_to_registered_virtual_tag_path()
    {
        // Companion to the rejection test: a write to a registered tag must continue to
        // load successfully.
        var up = new FakeUpstream();
        up.Set("In", 1);
        using var engine = Build(up);

        // No throw — Writer writes to Target which is registered.
        engine.Load([
            new VirtualTagDefinition("Target", DriverDataType.Int32,
                """return 0;""", ChangeTriggered: false),
            new VirtualTagDefinition("Writer", DriverDataType.Int32,
                """
                ctx.SetVirtualTag("Target", (int)ctx.GetTag("In").Value);
                return 0;
                """),
        ]);
        await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
        engine.Read("Target").Value.ShouldBe(1);
    }

    // ----- Core.VirtualTags-006: empty observer list left in _observers map -----

    /// <summary>Verifies that disposing the last subscriber for a path removes the empty observer list entry.</summary>
    [Fact]
    public void Subscribe_then_Unsub_prunes_empty_observer_list_for_path()
    {
        // Regression for Core.VirtualTags-006: disposing the last subscriber for a path
        // must remove the dictionary entry so a long-running server with churning OPC UA
        // subscriptions does not accumulate an unbounded number of empty List entries.
        var up = new FakeUpstream();
        using var engine = Build(up);
        engine.Load([new VirtualTagDefinition(
            "T", DriverDataType.Int32, """return 1;""")]);

        // Subscribe, then immediately Dispose — both the only observer.
        var sub1 = engine.Subscribe("T", (_, _) => { });
        var sub2 = engine.Subscribe("T", (_, _) => { });
        sub1.Dispose();
        sub2.Dispose();

        // The internal map should no longer hold an entry for the path.
        // Use the same ConcurrentDictionary type the engine uses; we check via reflection
        // on the test-private field so this is robust to future renames inside engine.
        var observersField = typeof(VirtualTagEngine).GetField(
            "_observers",
            System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
        observersField.ShouldNotBeNull();
        var observers = observersField!.GetValue(engine);
        observers.ShouldNotBeNull();
        var containsKey = observers!.GetType().GetMethod("ContainsKey")!;
        var result = (bool)containsKey.Invoke(observers, new object[] { "T" })!;
        result.ShouldBeFalse("disposing the last subscriber must remove the dictionary entry");
    }

    // ----- Core.VirtualTags-004: CoerceResult default arm leaks uncoerced values -----

    /// <summary>Verifies that CoerceResult correctly handles Int16, UInt16, UInt32, and UInt64 types.</summary>
    [Fact]
    public async Task CoerceResult_handles_Int16_UInt16_UInt32_UInt64()
    {
        // Regression for Core.VirtualTags-004: before the fix, CoerceResult had a default
        // arm that returned the script's raw double/string for these types, producing a
        // type-mismatched DataValueSnapshot. Verify every integer DriverDataType the engine
        // is allowed to declare coerces correctly.
        var up = new FakeUpstream();
        using var engine = Build(up);

        engine.Load([
            new VirtualTagDefinition("AsInt16",  DriverDataType.Int16,  """return 7.0;"""),
            new VirtualTagDefinition("AsUInt16", DriverDataType.UInt16, """return 8.0;"""),
            new VirtualTagDefinition("AsUInt32", DriverDataType.UInt32, """return 9.0;"""),
            new VirtualTagDefinition("AsUInt64", DriverDataType.UInt64, """return 10.0;"""),
        ]);
        await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);

        engine.Read("AsInt16").Value.ShouldBeOfType<short>();
        engine.Read("AsInt16").Value.ShouldBe((short)7);
        engine.Read("AsUInt16").Value.ShouldBeOfType<ushort>();
        engine.Read("AsUInt16").Value.ShouldBe((ushort)8);
        engine.Read("AsUInt32").Value.ShouldBeOfType<uint>();
        engine.Read("AsUInt32").Value.ShouldBe((uint)9);
        engine.Read("AsUInt64").Value.ShouldBeOfType<ulong>();
        engine.Read("AsUInt64").Value.ShouldBe((ulong)10);
    }

    /// <summary>Verifies that Load rejects virtual tag definitions with unsupported DriverDataType values.</summary>
    [Fact]
    public async Task Load_rejects_definition_with_unsupported_DriverDataType()
    {
        // Regression for Core.VirtualTags-004: any DriverDataType that CoerceResult cannot
        // honour must be rejected at Load time so an operator typo (or a future enum
        // member added without coercion support) does not silently emit a type-mismatched
        // value to OPC UA clients. Reference is unsupported for virtual tags (the engine
        // does not synthesize Galaxy attribute references).
        var up = new FakeUpstream();
        using var engine = Build(up);

        var ex = Should.Throw<InvalidOperationException>(() => engine.Load([
            new VirtualTagDefinition("Ref", DriverDataType.Reference, """return "Some.Attribute";"""),
        ]));
        ex.Message.ShouldContain("Reference");
        ex.Message.ShouldContain("Ref");
        await Task.CompletedTask;
    }

    /// <summary>Verifies that Load rejects duplicate virtual tag paths with an aggregated error message.</summary>
    [Fact]
    public async Task Load_rejects_duplicate_path_with_aggregated_error()
    {
        var up = new FakeUpstream();
        using var engine = Build(up);

        var ex = Should.Throw<InvalidOperationException>(() => engine.Load([
            new VirtualTagDefinition("Dup", DriverDataType.Int32, """return 1;"""),
            new VirtualTagDefinition("Dup", DriverDataType.Int32, """return 2;"""),
            new VirtualTagDefinition("Good", DriverDataType.Int32, """return 3;"""),
        ]));
        ex.Message.ShouldContain("Dup");
        ex.Message.ShouldContain("duplicate");
        await Task.CompletedTask;
    }

    /// <summary>Verifies that Read returns BadNodeIdUnknown for all paths before Load is called.</summary>
    [Fact]
    public void Read_before_Load_returns_BadNodeIdUnknown()
    {
        var up = new FakeUpstream();
        using var engine = Build(up);

        // Read is allowed before Load — it just returns BadNodeIdUnknown for everything.
        var result = engine.Read("AnyPath");
        result.StatusCode.ShouldBe(0x80340000u, "BadNodeIdUnknown before Load");
    }

    /// <summary>Verifies that EvaluateOneAsync throws InvalidOperationException when called before Load.</summary>
    [Fact]
    public void EvaluateOneAsync_before_Load_throws_InvalidOperationException()
    {
        var up = new FakeUpstream();
        using var engine = Build(up);

        Should.Throw<InvalidOperationException>(() =>
            engine.EvaluateOneAsync("A").GetAwaiter().GetResult());
    }

    /// <summary>Verifies that Subscribe does not throw when called before Load.</summary>
    [Fact]
    public void Subscribe_before_Load_does_not_throw()
    {
        // Subscribe uses GetOrAdd and does not call EnsureLoaded — it should work
        // (returns an Unsub handle) without a Load. The observer won't fire because
        // no tag is registered, but it must not throw.
        var up = new FakeUpstream();
        using var engine = Build(up);

        var fired = false;
        var sub = engine.Subscribe("AnyPath", (_, _) => fired = true);
        sub.ShouldNotBeNull();
        sub.Dispose();
        fired.ShouldBeFalse("no evaluation has happened, observer must not fire");
    }

    // ----- Core.VirtualTags-014: AreInputsReady must not block on Good-quality null value -----

    /// <summary>
    ///     Verifies that a Good-quality upstream snapshot with a null Value (e.g. a
    ///     script returning null) does not permanently block downstream change-triggered
    ///     dependents at BadWaitingForInitialData. A Good-quality null means "the
    ///     upstream is online and returned null" — readiness is determined by StatusCode,
    ///     not by the value's nullability.
    /// </summary>
    [Fact]
    public async Task AreInputsReady_Good_quality_null_upstream_does_not_block_downstream()
    {
        // Regression for Core.VirtualTags-014: before the fix, AreInputsReady checked
        // kv.Value.Value is null, so a Good-quality DataValueSnapshot(null, 0u, ...) —
        // produced by an upstream script returning null — kept every downstream stuck at
        // BadWaitingForInitialData forever. The fix gates only on StatusCode (bit 31).
        var up = new FakeUpstream();
        up.Set("In", 1);
        using var engine = Build(up);

        engine.Load([
            // Producer script returns null — stores DataValueSnapshot(null, Good, ...).
            new VirtualTagDefinition("NullProducer", DriverDataType.String,
                """return null;"""),
            // Consumer depends on NullProducer and is change-triggered.
            // With the bug: Consumer is stuck at BadWaitingForInitialData.
            // After the fix: Consumer evaluates and surfaces the NRE as BadInternalError
            // (since it casts null unconditionally), which is the correct sentinel for
            // "the script ran but produced an error" rather than "inputs not ready".
            new VirtualTagDefinition("Consumer", DriverDataType.String,
                """return (string)ctx.GetTag("NullProducer").Value + "_suffix";""",
                ChangeTriggered: true),
        ]);

        await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);

        // NullProducer should be Good with null value (script returned null).
        var producerResult = engine.Read("NullProducer");
        producerResult.StatusCode.ShouldBe(0u, "Good quality: script returned null cleanly");
        producerResult.Value.ShouldBeNull();

        // Consumer must NOT be stuck at BadWaitingForInitialData — it should have
        // evaluated (and produced BadInternalError from the cast of null + "_suffix").
        var consumerResult = engine.Read("Consumer");
        consumerResult.StatusCode.ShouldNotBe(0x80320000u,
            "Consumer must not be stuck at BadWaitingForInitialData when its upstream is Good-quality");
    }

    private static async Task WaitForConditionAsync(Func<bool> cond, int timeoutMs = 2000)
    {
        var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
        while (DateTime.UtcNow < deadline)
        {
            if (cond()) return;
            await Task.Delay(25);
        }
        throw new TimeoutException("Condition did not become true in time");
    }

    /// <summary>Test implementation of IHistoryWriter that records values to a buffer.</summary>
    private sealed class TestHistory : IHistoryWriter
    {
        private readonly List<(string, DataValueSnapshot)> _buf;

        /// <summary>Initializes a new instance of the TestHistory class with the specified buffer.</summary>
        /// <param name="buf">The list to store recorded history entries.</param>
        public TestHistory(List<(string, DataValueSnapshot)> buf) => _buf = buf;

        /// <summary>Records a virtual tag path and value snapshot to the history buffer.</summary>
        /// <param name="path">The virtual tag path.</param>
        /// <param name="value">The data value snapshot to record.</param>
        public void Record(string path, DataValueSnapshot value)
        {
            lock (_buf) { _buf.Add((path, value)); }
        }
    }

    // --- Core.Scripting-016: engine routes compiles through CompiledScriptCache ---

    /// <summary>Verifies that disposing the engine unloads the compiled script assembly.</summary>
    [Fact]
    public void Dispose_unloads_compiled_script_assembly()
    {
        // Pre-fix the engine called ScriptEvaluator.Compile directly, so the emitted
        // script assembly's ALC stayed loaded for the process lifetime. After the fix
        // the engine routes through CompiledScriptCache; engine Dispose triggers cache
        // Dispose which unloads every cached evaluator's ALC. Assert via WeakReference
        // + GC that the assembly is actually reclaimed.
        var weak = CompileTagAndCaptureWeak();
        for (int i = 0; i < 10 && weak.IsAlive; i++)
        {
            GC.Collect();
            GC.WaitForPendingFinalizers();
            GC.Collect();
        }
        weak.IsAlive.ShouldBeFalse(
            "engine Dispose must release the compiled-tag assembly via " +
            "CompiledScriptCache (Core.Scripting-016). If this fails the engine is " +
            "back to calling ScriptEvaluator.Compile directly and -008's headline " +
            "fix doesn't run in production.");
    }

    [System.Runtime.CompilerServices.MethodImpl(
        System.Runtime.CompilerServices.MethodImplOptions.NoInlining)]
    private static WeakReference CompileTagAndCaptureWeak()
    {
        var up = new FakeUpstream();
        up.Set("X", 10);
        var engine = Build(up);
        engine.Load([new VirtualTagDefinition(
            Path: "Doubled",
            DataType: DriverDataType.Float64,
            ScriptSource: """return (double)ctx.GetTag("X").Value * 2.0;""")]);

        // Reach into the engine's compile cache via reflection — internals scoped to
        // Tests-via-InternalsVisibleTo and the field is private.
        var cacheField = typeof(VirtualTagEngine).GetField(
            "_compileCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
        var cache = cacheField.GetValue(engine)!;
        var cacheDictField = cache.GetType().GetField(
            "_cache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
        var cacheDict = (System.Collections.IDictionary)cacheDictField.GetValue(cache)!;
        var lazy = cacheDict.Values.Cast<object>().Single();
        var lazyValueProp = lazy.GetType().GetProperty("Value")!;
        var evaluator = lazyValueProp.GetValue(lazy)!;
        var funcField = evaluator.GetType().GetField(
            "_func", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
        var del = (Delegate)funcField.GetValue(evaluator)!;
        var weak = new WeakReference(del.Method.Module.Assembly);

        engine.Dispose();
        return weak;
    }
}
