using System.Collections;
using System.Reflection;
using Microsoft.Extensions.Logging.Abstractions;
using Serilog;
using Serilog.Events;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Host.Engines;

namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;

/// <summary>
/// F8b — verifies <see cref="RoslynVirtualTagEvaluator"/> compiles user expressions through
/// the Core.Scripting sandbox, runs them against the dependency dictionary, caches the
/// compiled assembly per source, and surfaces failures (compile error, sandbox violation,
/// runtime throw) as <c>VirtualTagEvalResult.Failure</c> instead of propagating exceptions.
/// </summary>
public sealed class RoslynVirtualTagEvaluatorTests
{
    /// <summary>Captures published <see cref="ScriptLogEntry"/> records for assertion.</summary>
    private sealed class FakePublisher : IScriptLogPublisher
    {
        /// <summary>Gets the entries published so far.</summary>
        public List<ScriptLogEntry> Published { get; } = [];

        /// <inheritdoc/>
        public void Publish(ScriptLogEntry entry) => Published.Add(entry);
    }

    /// <summary>Builds a no-op <see cref="ScriptRootLogger"/> for tests that don't assert on logging.</summary>
    private static ScriptRootLogger NoOpScriptRoot() =>
        new(new LoggerConfiguration().CreateLogger());

    /// <summary>Verifies that simple addition expression is evaluated correctly.</summary>
    [Fact]
    public void Evaluate_simple_addition_returns_summed_value()
    {
        using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());

        var result = sut.Evaluate(
            virtualTagId: "vt-sum",
            expression: "return (int)ctx.GetTag(\"a\").Value + (int)ctx.GetTag(\"b\").Value;",
            dependencies: new Dictionary<string, object?> { ["a"] = 10, ["b"] = 32 });

        result.Success.ShouldBeTrue(result.Reason);
        result.Value.ShouldBe(42);
    }

    /// <summary>Verifies that compiled expressions are cached across multiple calls.</summary>
    [Fact]
    public void Evaluate_caches_compiled_expression_across_calls()
    {
        using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
        const string expr = "return (int)ctx.GetTag(\"x\").Value * 2;";

        var first = sut.Evaluate("vt-cache", expr, new Dictionary<string, object?> { ["x"] = 5 });
        var second = sut.Evaluate("vt-cache", expr, new Dictionary<string, object?> { ["x"] = 7 });

        first.Success.ShouldBeTrue(first.Reason);
        first.Value.ShouldBe(10);
        second.Success.ShouldBeTrue(second.Reason);
        second.Value.ShouldBe(14);
    }

    /// <summary>Verifies that compile errors return Failure with a descriptive reason.</summary>
    [Fact]
    public void Evaluate_compile_error_returns_Failure_with_reason()
    {
        using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());

        var result = sut.Evaluate("vt-bad", "this is not valid C#;", new Dictionary<string, object?>());

        result.Success.ShouldBeFalse();
        result.Reason.ShouldNotBeNull();
        result.Reason.ShouldContain("compile");
    }

    /// <summary>Verifies that runtime exceptions return Failure with a descriptive reason.</summary>
    [Fact]
    public void Evaluate_runtime_exception_returns_Failure_with_reason()
    {
        using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());

        var result = sut.Evaluate(
            virtualTagId: "vt-div0",
            expression: "int a = 0; return 1 / a;",
            dependencies: new Dictionary<string, object?>());

        result.Success.ShouldBeFalse();
        result.Reason.ShouldNotBeNull();
        result.Reason.ShouldContain("threw");
    }

    /// <summary>Verifies that empty expressions return Failure.</summary>
    [Fact]
    public void Evaluate_empty_expression_returns_Failure()
    {
        using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());

        sut.Evaluate("vt-empty", "", new Dictionary<string, object?>()).Success.ShouldBeFalse();
        sut.Evaluate("vt-empty", "   ", new Dictionary<string, object?>()).Success.ShouldBeFalse();
    }

    /// <summary>Verifies that evaluation after disposal returns Failure.</summary>
    [Fact]
    public void Evaluate_after_dispose_returns_Failure()
    {
        var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
        sut.Dispose();

        var result = sut.Evaluate("vt", "return 1;", new Dictionary<string, object?>());

        result.Success.ShouldBeFalse();
        result.Reason!.ShouldContain("disposed");
    }

    // ── A — passthrough fast-path: the mirror shape `return ctx.GetTag("X").Value;`
    //    is answered directly from the dependency value, skipping Roslyn compilation. ──

    /// <summary>Mirror passthrough returns the dependency value verbatim without compiling.</summary>
    [Fact]
    public void Passthrough_returns_dependency_value_without_compiling()
    {
        using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());

        var result = sut.Evaluate(
            virtualTagId: "vt-mirror",
            expression: "return ctx.GetTag(\"a\").Value;",
            dependencies: new Dictionary<string, object?> { ["a"] = 42 });

        result.Success.ShouldBeTrue(result.Reason);
        result.Value.ShouldBe(42);
    }

    /// <summary>The passthrough fast-path returns the raw object reference, not a re-boxed copy —
    /// proof the value flowed straight through without round-tripping a Roslyn script run.</summary>
    [Fact]
    public void Passthrough_returns_same_object_reference_as_dependency()
    {
        using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
        var payload = new object();

        var result = sut.Evaluate(
            virtualTagId: "vt-ref",
            expression: "return ctx.GetTag(\"a\").Value;",
            dependencies: new Dictionary<string, object?> { ["a"] = payload });

        result.Success.ShouldBeTrue(result.Reason);
        result.Value.ShouldBeSameAs(payload);
    }

    /// <summary>Whitespace-tolerant mirror shapes still take the passthrough fast-path.</summary>
    [Fact]
    public void Passthrough_whitespace_variants_match()
    {
        using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());

        var result = sut.Evaluate(
            virtualTagId: "vt-ws",
            expression: "  return  ctx . GetTag( \"a\" ) . Value ; ",
            dependencies: new Dictionary<string, object?> { ["a"] = 7 });

        result.Success.ShouldBeTrue(result.Reason);
        result.Value.ShouldBe(7);
    }

    /// <summary>A near-miss (arithmetic on the mirror value) falls through to Roslyn and still works.</summary>
    [Fact]
    public void Non_passthrough_falls_through_to_Roslyn()
    {
        using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());

        var result = sut.Evaluate(
            virtualTagId: "vt-plus1",
            expression: "return (int)ctx.GetTag(\"a\").Value + 1;",
            dependencies: new Dictionary<string, object?> { ["a"] = 42 });

        result.Success.ShouldBeTrue(result.Reason);
        result.Value.ShouldBe(43);
    }

    /// <summary>Passthrough with an absent dependency yields the SAME result the Roslyn path
    /// produces: <c>GetTag</c> returns a Bad snapshot whose <c>.Value</c> is null, so the script
    /// returns null and the evaluator wraps it as <c>Ok(null)</c> (success, null value).</summary>
    [Fact]
    public void Passthrough_missing_dependency_matches_Roslyn_behaviour()
    {
        using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
        const string mirror = "return ctx.GetTag(\"missing\").Value;";

        // Roslyn baseline: same source, but force a near-miss that compiles, to capture the
        // not-found semantics independently. Here we just assert the mirror's own missing-dep
        // result equals the documented Ok(null) shape.
        var passthrough = sut.Evaluate("vt-miss", mirror, new Dictionary<string, object?>());

        passthrough.Success.ShouldBeTrue(passthrough.Reason);
        passthrough.Value.ShouldBeNull();
        passthrough.Reason.ShouldBeNull();
    }

    /// <summary>Cross-check: the equivalent Roslyn-compiled read of a missing dependency
    /// produces exactly the same <c>Ok(null)</c> result, proving the fast-path is byte-identical.</summary>
    [Fact]
    public void Roslyn_missing_dependency_also_returns_Ok_null()
    {
        using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
        // `(object?)...Value` forces the compiled path (not the mirror shape) but reads the same
        // missing tag; result must match the passthrough missing-dep result above.
        var result = sut.Evaluate(
            "vt-miss-roslyn",
            "return (object?)ctx.GetTag(\"missing\").Value;",
            new Dictionary<string, object?>());

        result.Success.ShouldBeTrue(result.Reason);
        result.Value.ShouldBeNull();
        result.Reason.ShouldBeNull();
    }

    /// <summary>Direct parity test: the EXACT mirror source <c>return ctx.GetTag("missing").Value;</c>
    /// evaluated against an empty dependency dictionary takes the fast-path and yields
    /// <c>Ok(null)</c>; a minimally-altered but semantically-identical non-passthrough variant
    /// <c>return (object?)ctx.GetTag("missing").Value;</c> compiled by Roslyn against the same
    /// empty deps yields the identical <c>Ok(null)</c> — proving the fast-path is byte-identical
    /// to the Roslyn path for the missing-dependency case.</summary>
    [Fact]
    public void Passthrough_exact_mirror_missing_dep_matches_Roslyn_baseline()
    {
        using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());

        // Fast-path: exact mirror shape → passthrough returns Ok(null) for missing dep.
        var fastPath = sut.Evaluate(
            "vt-parity-fast",
            "return ctx.GetTag(\"missing\").Value;",
            new Dictionary<string, object?>());

        fastPath.Success.ShouldBeTrue(fastPath.Reason);
        fastPath.Value.ShouldBeNull();
        fastPath.Reason.ShouldBeNull();

        // Roslyn path: `(object?)` cast forces compilation but reads the same missing tag.
        var roslynPath = sut.Evaluate(
            "vt-parity-roslyn",
            "return (object?)ctx.GetTag(\"missing\").Value;",
            new Dictionary<string, object?>());

        roslynPath.Success.ShouldBeTrue(roslynPath.Reason);
        roslynPath.Value.ShouldBeNull();
        roslynPath.Reason.ShouldBeNull();
    }

    /// <summary>Decisive proof the fast-path skips Roslyn: the compiled-script cache (which every
    /// Roslyn evaluation populates via <c>GetOrCompile</c>) stays EMPTY after a mirror evaluation, then
    /// grows to one entry once a genuine (non-mirror) expression forces compilation.</summary>
    [Fact]
    public void Passthrough_does_not_populate_the_compiled_script_cache()
    {
        using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());

        // Mirror shape — must take the fast-path, leaving the Roslyn cache untouched.
        sut.Evaluate("vt-mirror", "return ctx.GetTag(\"a\").Value;",
            new Dictionary<string, object?> { ["a"] = 1 });
        CompiledCacheCount(sut).ShouldBe(0);

        // Non-mirror shape — must compile, populating exactly one cache entry.
        sut.Evaluate("vt-real", "return (int)ctx.GetTag(\"a\").Value + 1;",
            new Dictionary<string, object?> { ["a"] = 1 });
        CompiledCacheCount(sut).ShouldBe(1);
    }

    /// <summary>
    /// A script's <c>ctx.Logger.*</c> call flows through the injected root script logger and out
    /// the <see cref="ScriptLogTopicSink"/>, producing exactly one <see cref="ScriptLogEntry"/>
    /// carrying the message and the bound identity (<c>ScriptId</c> + <c>VirtualTagId</c> both
    /// equal the virtual-tag id in the live path).
    /// </summary>
    [Fact]
    public void Script_logger_call_publishes_entry_with_bound_identity()
    {
        var publisher = new FakePublisher();
        var root = new LoggerConfiguration()
            .MinimumLevel.Verbose()
            .WriteTo.Sink(new ScriptLogTopicSink(publisher, LogEventLevel.Information))
            .CreateLogger();
        using var sut = new RoslynVirtualTagEvaluator(
            NullLogger<RoslynVirtualTagEvaluator>.Instance, new ScriptRootLogger(root));

        var result = sut.Evaluate(
            virtualTagId: "vt-log",
            expression: "ctx.Logger.Information(\"hi from script\"); return 1;",
            dependencies: new Dictionary<string, object?>());

        result.Success.ShouldBeTrue(result.Reason);
        publisher.Published.Count.ShouldBe(1);
        var entry = publisher.Published[0];
        entry.Message.ShouldBe("hi from script");
        entry.ScriptId.ShouldBe("vt-log");
        entry.VirtualTagId.ShouldBe("vt-log");
    }

    // ── U2 — production timeout must be REAL: a CPU-bound/infinite-loop script must fail within the
    //    wall-clock budget, never hang the owning actor. The pre-fix CTS-only path could not interrupt
    //    a synchronous Roslyn script, so this class of script wedged the VirtualTagActor forever. ──

    /// <summary>An infinite-loop script returns Failure ("timed out") within the timeout budget instead
    /// of hanging. The whole call is bounded by <c>WaitAsync</c> so a regression FAILS the test (with a
    /// TimeoutException) rather than wedging the suite.</summary>
    [Fact]
    public async Task Evaluate_infinite_loop_script_fails_within_timeout_and_does_not_hang()
    {
        using var sut = new RoslynVirtualTagEvaluator(
            NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot(), TimeSpan.FromMilliseconds(200));

        var evalTask = Task.Run(
            () => sut.Evaluate("vt-loop", "while(true){}return 0;", new Dictionary<string, object?>()),
            TestContext.Current.CancellationToken);

        var result = await evalTask.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);

        result.Success.ShouldBeFalse();
        result.Reason.ShouldNotBeNull();
        result.Reason.ShouldContain("timed out");
    }

    /// <summary>Regression: routing through <see cref="TimedScriptEvaluator{TContext, TResult}"/> did not
    /// break the happy path — a fast compiled script still returns its value well within the budget.</summary>
    [Fact]
    public void Evaluate_fast_script_still_returns_value_after_timeout_wrapping()
    {
        using var sut = new RoslynVirtualTagEvaluator(
            NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot(), TimeSpan.FromMilliseconds(200));

        var result = sut.Evaluate("vt-fast", "return (int)ctx.GetTag(\"a\").Value + 1;",
            new Dictionary<string, object?> { ["a"] = 41 });

        result.Success.ShouldBeTrue(result.Reason);
        result.Value.ShouldBe(42);
    }

    // ── U3 — the apply-boundary clear (IScriptCacheOwner) drops compiled entries so their collectible
    //    AssemblyLoadContexts are reclaimed. (The ALC-unload-on-Clear proof lives in CompiledScriptCacheTests;
    //    here we assert the adapter's ClearCompiledScripts actually empties its cache.) ──

    /// <summary>Verifies <see cref="RoslynVirtualTagEvaluator.ClearCompiledScripts"/> drops every cached
    /// compile — the hook the host actor calls per deploy generation to release stale ALCs.</summary>
    [Fact]
    public void ClearCompiledScripts_empties_the_compile_cache()
    {
        using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());

        sut.Evaluate("vt-a", "return (int)ctx.GetTag(\"a\").Value + 1;", new Dictionary<string, object?> { ["a"] = 1 });
        CompiledCacheCount(sut).ShouldBe(1);

        sut.ClearCompiledScripts();

        CompiledCacheCount(sut).ShouldBe(0);
    }

    /// <summary>02/S12 sequential pin: evaluating a compiled expression, clearing the cache, then
    /// evaluating the SAME source again succeeds both times and leaves the cache with exactly one entry
    /// (the recompile). This pins the clear-then-recompile contract the T8 retry-loop restructure must
    /// not regress.</summary>
    [Fact]
    public void Evaluate_after_ClearCompiledScripts_recompiles_and_succeeds()
    {
        using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
        const string expr = "return (int)ctx.GetTag(\"a\").Value + 1;";

        var first = sut.Evaluate("vt-clear", expr, new Dictionary<string, object?> { ["a"] = 1 });
        first.Success.ShouldBeTrue(first.Reason);
        CompiledCacheCount(sut).ShouldBe(1);

        sut.ClearCompiledScripts();
        CompiledCacheCount(sut).ShouldBe(0);

        var second = sut.Evaluate("vt-clear", expr, new Dictionary<string, object?> { ["a"] = 41 });
        second.Success.ShouldBeTrue(second.Reason);
        second.Value.ShouldBe(42);
        CompiledCacheCount(sut).ShouldBe(1);
    }

    /// <summary>02/S12 concurrent stress repro (RED on current code): while one loop runs many compiled
    /// (non-passthrough) evaluations of the same expression, a second loop hammers
    /// <see cref="RoslynVirtualTagEvaluator.ClearCompiledScripts"/> — the apply-boundary clear. On the
    /// unfixed tree a clear landing between a child's <c>GetOrCompile</c> and the run's disposed-guard
    /// surfaces an ObjectDisposedException as a <c>Failure("script threw: Cannot access a disposed
    /// object…")</c>; the fix retries once and every result stays Success. The whole test is WaitAsync-
    /// bounded so a regression fails fast rather than hanging.</summary>
    [Fact]
    public async Task Evaluate_racing_ClearCompiledScripts_never_fails_with_disposed()
    {
        // Generous timeout so the wall-clock budget never fires and confounds the race assertion.
        using var sut = new RoslynVirtualTagEvaluator(
            NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot(), TimeSpan.FromSeconds(5));
        const string expr = "return (int)ctx.GetTag(\"a\").Value + 1;";

        const int loopCount = 12;
        const int iterations = 400;
        var failures = new System.Collections.Concurrent.ConcurrentBag<string>();
        var remaining = loopCount; // number of concurrent evaluation loops still running

        // Many concurrent evaluation loops put the thread pool under enough pressure that the inner
        // Task.Run hop inside TimedScriptEvaluator is measurably delayed — widening the
        // fetch→run-disposed-guard window so a clear reliably lands inside SOME in-flight evaluation.
        // The clear loop clears on a short spin cadence (not back-to-back) so (a) most evaluations hit
        // the warm cache — keeping compile cost bounded — and (b) at most one clear lands within a
        // single evaluation, so the single retry the fix adds is sufficient (a back-to-back clear loop
        // would both compile-swamp the RED run into a timeout and defeat the one-shot retry). Pre-fix a
        // clear disposing the just-fetched evaluator surfaces as Failure("script threw: Cannot access a
        // disposed object…"); post-fix the retry re-fetches into the current generation → all Success.
        var evalLoops = Enumerable.Range(0, loopCount).Select(_ => Task.Run(() =>
        {
            for (var i = 0; i < iterations; i++)
            {
                var r = sut.Evaluate("vt-race", expr, new Dictionary<string, object?> { ["a"] = i });
                if (!r.Success)
                {
                    failures.Add(r.Reason ?? "<null reason>");
                }
            }
            Interlocked.Decrement(ref remaining);
        }, TestContext.Current.CancellationToken)).ToArray();

        var clearLoop = Task.Run(() =>
        {
            while (Volatile.Read(ref remaining) > 0)
            {
                sut.ClearCompiledScripts();
                Thread.SpinWait(4000); // ~tens of µs between clears — frequent, but not back-to-back
            }
        }, TestContext.Current.CancellationToken);

        await Task.WhenAll(evalLoops.Append(clearLoop))
            .WaitAsync(TimeSpan.FromSeconds(45), TestContext.Current.CancellationToken);

        failures.ShouldBeEmpty(
            $"every evaluation must succeed despite racing clears; observed: {string.Join(" | ", failures)}");
    }

    /// <summary>Reads the count of the private compiled-script cache via reflection. The cache is a
    /// <c>CompiledScriptCache&lt;,&gt;</c> which exposes a <c>Count</c> property (not <c>ICollection</c>).</summary>
    private static int CompiledCacheCount(RoslynVirtualTagEvaluator sut)
    {
        var field = typeof(RoslynVirtualTagEvaluator)
            .GetField("_cache", BindingFlags.Instance | BindingFlags.NonPublic);
        field.ShouldNotBeNull();
        var cache = field.GetValue(sut)!;
        var countProp = cache.GetType().GetProperty("Count");
        countProp.ShouldNotBeNull();
        return (int)countProp.GetValue(cache)!;
    }
}
