using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;

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

/// <summary>
///     Exercises the source-hash keyed compile cache. Roslyn compilation is the most
///     expensive step in the evaluator pipeline; this cache collapses redundant
///     compiles of unchanged scripts to zero-cost lookups + makes sure concurrent
///     callers never double-compile.
/// </summary>
[Trait("Category", "Unit")]
public sealed class CompiledScriptCacheTests
{
    private sealed class CompileCountingGate
    {
        public int Count;
    }

    [Fact]
    public void First_call_compiles_and_caches()
    {
        var cache = new CompiledScriptCache<FakeScriptContext, int>();
        cache.Count.ShouldBe(0);

        var e = cache.GetOrCompile("""return 42;""");
        e.ShouldNotBeNull();
        cache.Count.ShouldBe(1);
        cache.Contains("""return 42;""").ShouldBeTrue();
    }

    [Fact]
    public void Identical_source_returns_the_same_compiled_evaluator()
    {
        var cache = new CompiledScriptCache<FakeScriptContext, int>();
        var first = cache.GetOrCompile("""return 1;""");
        var second = cache.GetOrCompile("""return 1;""");
        ReferenceEquals(first, second).ShouldBeTrue();
        cache.Count.ShouldBe(1);
    }

    [Fact]
    public void Different_source_produces_different_evaluator()
    {
        var cache = new CompiledScriptCache<FakeScriptContext, int>();
        var a = cache.GetOrCompile("""return 1;""");
        var b = cache.GetOrCompile("""return 2;""");
        ReferenceEquals(a, b).ShouldBeFalse();
        cache.Count.ShouldBe(2);
    }

    [Fact]
    public void Whitespace_difference_misses_cache()
    {
        // Documented behavior: reformatting a script recompiles. Simpler + cheaper
        // than the alternative (AST-canonicalize then hash) and doesn't happen often.
        var cache = new CompiledScriptCache<FakeScriptContext, int>();
        cache.GetOrCompile("""return 1;""");
        cache.GetOrCompile("return 1;  "); // trailing whitespace — different hash
        cache.Count.ShouldBe(2);
    }

    [Fact]
    public async Task Cached_evaluator_still_runs_correctly()
    {
        var cache = new CompiledScriptCache<FakeScriptContext, double>();
        var e = cache.GetOrCompile("""return (double)ctx.GetTag("In").Value * 3.0;""");
        var ctx = new FakeScriptContext().Seed("In", 7.0);

        // Run twice through the cache — both must return the same correct value.
        var first = await e.RunAsync(ctx, TestContext.Current.CancellationToken);
        var second = await cache.GetOrCompile("""return (double)ctx.GetTag("In").Value * 3.0;""")
            .RunAsync(ctx, TestContext.Current.CancellationToken);
        first.ShouldBe(21.0);
        second.ShouldBe(21.0);
    }

    [Fact]
    public void Failed_compile_is_evicted_so_retry_with_corrected_source_works()
    {
        var cache = new CompiledScriptCache<FakeScriptContext, int>();

        // First attempt — undefined identifier, compile throws.
        Should.Throw<Exception>(() => cache.GetOrCompile("""return unknownIdentifier + 1;"""));
        cache.Count.ShouldBe(0, "failed compile must be evicted so retry can re-attempt");

        // Retry with corrected source succeeds + caches.
        cache.GetOrCompile("""return 42;""").ShouldNotBeNull();
        cache.Count.ShouldBe(1);
    }

    [Fact]
    public void Clear_drops_every_entry()
    {
        var cache = new CompiledScriptCache<FakeScriptContext, int>();
        cache.GetOrCompile("""return 1;""");
        cache.GetOrCompile("""return 2;""");
        cache.Count.ShouldBe(2);

        cache.Clear();
        cache.Count.ShouldBe(0);
        cache.Contains("""return 1;""").ShouldBeFalse();
    }

    [Fact]
    public void Concurrent_compiles_of_the_same_source_deduplicate()
    {
        // LazyThreadSafetyMode.ExecutionAndPublication guarantees only one compile
        // even when multiple threads race GetOrCompile against an empty cache.
        // We can't directly count Roslyn compilations — but we can assert all
        // concurrent callers see the same evaluator instance.
        var cache = new CompiledScriptCache<FakeScriptContext, int>();
        const string src = """return 99;""";

        var tasks = Enumerable.Range(0, 20)
            .Select(_ => Task.Run(() => cache.GetOrCompile(src)))
            .ToArray();
        Task.WhenAll(tasks).GetAwaiter().GetResult();

        var firstInstance = tasks[0].Result;
        foreach (var t in tasks)
            ReferenceEquals(t.Result, firstInstance).ShouldBeTrue();
        cache.Count.ShouldBe(1);
    }

    [Fact]
    public void Different_TContext_TResult_pairs_use_separate_cache_instances()
    {
        // Documented: each engine (virtual-tag / alarm-predicate / alarm-action) owns
        // its own cache. The type-parametric design makes this the default without
        // cross-contamination at the dictionary level.
        var intCache = new CompiledScriptCache<FakeScriptContext, int>();
        var boolCache = new CompiledScriptCache<FakeScriptContext, bool>();

        intCache.GetOrCompile("""return 1;""");
        boolCache.GetOrCompile("""return true;""");

        intCache.Count.ShouldBe(1);
        boolCache.Count.ShouldBe(1);
        intCache.Contains("""return true;""").ShouldBeFalse();
        boolCache.Contains("""return 1;""").ShouldBeFalse();
    }

    [Fact]
    public void Null_source_throws_ArgumentNullException()
    {
        var cache = new CompiledScriptCache<FakeScriptContext, int>();
        Should.Throw<ArgumentNullException>(() => cache.GetOrCompile(null!));
    }
}
