using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Scripting;

namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests;

/// <summary>
/// Pins the per-compile native-metadata fix (2026-08-12 follow-up to 5a781c70):
/// Roslyn's default ScriptMetadataResolver re-resolves the transitive assembly
/// closure through MetadataReference.CreateFromFile on EVERY script compile —
/// each call minting a fresh AssemblyMetadata → PEReader → NativeHeapMemoryBlock.
/// The decorator memoizes so each distinct assembly is resolved once per process.
/// </summary>
public class CachingScriptMetadataResolverTests
{
    private sealed class CountingResolver : MetadataReferenceResolver
    {
        public int MissingCalls;
        public int ReferenceCalls;
        public override bool ResolveMissingAssemblies => true;

        public override PortableExecutableReference? ResolveMissingAssembly(
            MetadataReference definition, AssemblyIdentity referenceIdentity)
        {
            Interlocked.Increment(ref MissingCalls);
            return MetadataReference.CreateFromFile(typeof(object).Assembly.Location);
        }

        public override ImmutableArray<PortableExecutableReference> ResolveReference(
            string reference, string? baseFilePath, MetadataReferenceProperties properties)
        {
            Interlocked.Increment(ref ReferenceCalls);
            return [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)];
        }

        public override bool Equals(object? other) => ReferenceEquals(this, other);
        public override int GetHashCode() => 0;
    }

    /// <summary>Inner resolver that finds nothing — models an assembly absent from every search path.</summary>
    private sealed class NullReturningResolver : MetadataReferenceResolver
    {
        public int MissingCalls;
        public override bool ResolveMissingAssemblies => true;

        public override PortableExecutableReference? ResolveMissingAssembly(
            MetadataReference definition, AssemblyIdentity referenceIdentity)
        {
            Interlocked.Increment(ref MissingCalls);
            return null;
        }

        public override ImmutableArray<PortableExecutableReference> ResolveReference(
            string reference, string? baseFilePath, MetadataReferenceProperties properties)
            => [];

        public override bool Equals(object? other) => ReferenceEquals(this, other);
        public override int GetHashCode() => 0;
    }

    private static readonly AssemblyIdentity SomeIdentity = new("System.Fake", new Version(1, 0, 0, 0));
    private static readonly MetadataReference SomeDefinition =
        MetadataReference.CreateFromFile(typeof(object).Assembly.Location);

    [Fact]
    public void ResolveMissingAssembly_SameIdentityTwice_ResolvesOnceAndSharesInstance()
    {
        var inner = new CountingResolver();
        var sut = new CachingScriptMetadataResolver(inner);

        var first = sut.ResolveMissingAssembly(SomeDefinition, SomeIdentity);
        var second = sut.ResolveMissingAssembly(SomeDefinition, SomeIdentity);

        Assert.Equal(1, inner.MissingCalls);
        Assert.Same(first, second); // one AssemblyMetadata/PEReader/native block, not two
    }

    [Fact]
    public void ResolveMissingAssembly_DistinctIdentities_ResolveIndependently()
    {
        var inner = new CountingResolver();
        var sut = new CachingScriptMetadataResolver(inner);

        sut.ResolveMissingAssembly(SomeDefinition, SomeIdentity);
        sut.ResolveMissingAssembly(SomeDefinition, new AssemblyIdentity("System.Other", new Version(1, 0, 0, 0)));

        Assert.Equal(2, inner.MissingCalls);
    }

    /// <summary>
    /// A not-found result is cached and sticky BY DESIGN: the inner resolver
    /// searches a fixed set of directories that does not change over a node's
    /// lifetime, so re-asking for an assembly that was not there only pays the
    /// probe cost again. Pinned because caching nulls is the kind of thing a
    /// future reader "fixes" without realising it is deliberate.
    /// </summary>
    [Fact]
    public void ResolveMissingAssembly_NotFound_CachesTheNullResult()
    {
        var inner = new NullReturningResolver();
        var sut = new CachingScriptMetadataResolver(inner);

        var first = sut.ResolveMissingAssembly(SomeDefinition, SomeIdentity);
        var second = sut.ResolveMissingAssembly(SomeDefinition, SomeIdentity);

        Assert.Equal(1, inner.MissingCalls);
        Assert.Null(first);
        Assert.Null(second);
    }

    /// <summary>
    /// Locks in the documented cache-key contract: the key is the assembly
    /// identity ALONE, and the requesting <c>definition</c> (whose directory is a
    /// search path in the inner resolver) is deliberately ignored, because every
    /// ScadaBridge node resolves from one publish directory plus the shared
    /// framework. If that assumption ever stops holding, this test fails and the
    /// widening becomes an intentional diff rather than a silent one.
    /// </summary>
    [Fact]
    public void ResolveMissingAssembly_SameIdentityDifferentDefinitions_ResolvesOnce()
    {
        var otherDefinition = MetadataReference.CreateFromFile(typeof(Enumerable).Assembly.Location);
        Assert.NotEqual(
            typeof(object).Assembly.Location,
            typeof(Enumerable).Assembly.Location); // guard: the two definitions really are distinct files

        var inner = new CountingResolver();
        var sut = new CachingScriptMetadataResolver(inner);

        sut.ResolveMissingAssembly(SomeDefinition, SomeIdentity);
        sut.ResolveMissingAssembly(otherDefinition, SomeIdentity);

        Assert.Equal(1, inner.MissingCalls);
    }

    [Fact]
    public void ResolveReference_SameArgsTwice_ResolvesOnceAndSharesInstances()
    {
        var inner = new CountingResolver();
        var sut = new CachingScriptMetadataResolver(inner);

        var first = sut.ResolveReference("System.Xml", baseFilePath: null, MetadataReferenceProperties.Assembly);
        var second = sut.ResolveReference("System.Xml", baseFilePath: null, MetadataReferenceProperties.Assembly);

        Assert.Equal(1, inner.ReferenceCalls);
        Assert.Same(first[0], second[0]);
    }

    [Fact]
    public void Instance_IsProcessWideSingleton()
        => Assert.Same(CachingScriptMetadataResolver.Instance, CachingScriptMetadataResolver.Instance);

    [Fact]
    public void ResolveMissingAssemblies_DelegatesToInner()
    {
        var sut = new CachingScriptMetadataResolver(new CountingResolver());
        Assert.True(sut.ResolveMissingAssemblies);
    }

    /// <summary>
    /// The decisive regression: with options shaped like the real compile
    /// surfaces (small explicit set, large transitive closure), the FIRST
    /// compile resolves the closure through the resolver and every subsequent
    /// compile — same or different code — resolves NOTHING. Before the fix,
    /// every compile re-resolved the full closure (measured: 74 fresh
    /// PortableExecutableReferences per compile on Roslyn 5.0.0).
    /// A probe wraps the DEFAULT resolver so the test also proves resolution
    /// actually flows through this path at all (first > 0) — guarding against
    /// the whole mechanism silently changing in a Roslyn upgrade.
    /// </summary>
    [Fact]
    public void Compile_RepeatAndDistinctScripts_ResolveClosureOnlyOnce()
    {
        var probe = new ProbeResolver(ScriptOptions.Default.MetadataResolver);
        var caching = new CachingScriptMetadataResolver(probe);

        var options = ScriptOptions.Default
            .WithReferences(
                typeof(object).Assembly,
                typeof(Enumerable).Assembly,
                typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly)
            .WithImports("System", "System.Linq")
            .WithMetadataResolver(caching);

        long CompileAndCount(string code)
        {
            long before = probe.MissingCalls;
            var script = Microsoft.CodeAnalysis.CSharp.Scripting.CSharpScript.Create<object?>(code, options);
            var errors = script.Compile().Count(d => d.Severity == DiagnosticSeverity.Error);
            Assert.Equal(0, errors);
            return probe.MissingCalls - before;
        }

        var first = CompileAndCount("return Enumerable.Range(1, 3).Sum();");
        var repeat = CompileAndCount("return Enumerable.Range(1, 3).Sum();");
        var distinct = CompileAndCount("return string.Join(\",\", Enumerable.Range(1, 2)).Length;");

        Assert.True(first > 0, "expected the first compile to resolve the transitive closure through the resolver");
        Assert.Equal(0, repeat);   // pre-fix: == first (measured 20 for this test's reference set; ~74 on the real compile surfaces)
        Assert.Equal(0, distinct);
    }

    private sealed class ProbeResolver : MetadataReferenceResolver
    {
        private readonly MetadataReferenceResolver _inner;
        public long MissingCalls;
        public ProbeResolver(MetadataReferenceResolver inner) => _inner = inner;
        public override bool ResolveMissingAssemblies => _inner.ResolveMissingAssemblies;
        public override PortableExecutableReference? ResolveMissingAssembly(
            MetadataReference definition, AssemblyIdentity referenceIdentity)
        {
            Interlocked.Increment(ref MissingCalls);
            return _inner.ResolveMissingAssembly(definition, referenceIdentity);
        }
        public override ImmutableArray<PortableExecutableReference> ResolveReference(
            string reference, string? baseFilePath, MetadataReferenceProperties properties)
            => _inner.ResolveReference(reference, baseFilePath, properties);
        public override bool Equals(object? other) => ReferenceEquals(this, other);
        public override int GetHashCode() => 0;
    }
}
