using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;

namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.Validation;

/// <summary>
/// WP2.5: the verdict cache's eviction policy. Overflow used to <c>Clear()</c> the
/// whole cache, which re-opened the non-collectible <c>InteractiveAssemblyLoader</c>
/// leak the cache exists to bound — every hot script had to be recompiled, and every
/// recompile loads another assembly that can never be unloaded. These tests pin the
/// replacement: eviction is segmented, so entries in active use survive it.
///
/// <para>
/// Serialised with the other verdict-cache tests: the cache is process-wide static
/// state, so two test classes filling it concurrently would see each other's
/// entries.
/// </para>
/// </summary>
[Collection("ScriptCompileVerdictCache")]
public class ScriptCompileVerdictCacheEvictionTests
{
    private const string Surface = "TestSurface";

    /// <summary>Entries needed to force at least one generation rotation.</summary>
    private const int OverflowCount = 5000;

    private static (bool Ok, string? Error) Lookup(string code, Func<(bool, string?)> factory) =>
        ScriptCompileVerdictCache.GetOrAdd(Surface, code, factory);

    [Fact]
    public void Overflow_KeepsHotEntry_AndDoesNotClearEverything()
    {
        ScriptCompileVerdictCache.Clear();

        const string hotCode = "// the script every deploy re-validates";
        var hotCompiles = 0;
        (bool, string?) HotFactory()
        {
            hotCompiles++;
            return (true, null);
        }

        Lookup(hotCode, HotFactory);
        Assert.Equal(1, hotCompiles);

        // Push far more distinct scripts through than the cache can hold, touching
        // the hot entry as we go — which is exactly what a real workload does, and
        // exactly what wholesale Clear() used to throw away.
        for (var i = 0; i < OverflowCount; i++)
        {
            Lookup($"// filler {i}", static () => (true, null));
            if (i % 25 == 0)
                Lookup(hotCode, HotFactory);
        }

        // At least one rotation happened...
        Assert.True(ScriptCompileVerdictCache.Evictions > 0,
            "the overflow did not trigger a single eviction — the test no longer exercises the policy");

        // ...and the hot entry was never recompiled, because a hit in the cold
        // generation promotes it back into hot rather than letting it age out.
        Assert.Equal(1, hotCompiles);

        // A final read still hits.
        var compilesBefore = hotCompiles;
        Lookup(hotCode, HotFactory);
        Assert.Equal(compilesBefore, hotCompiles);
    }

    [Fact]
    public void Overflow_RetainsRecentEntries_RatherThanDroppingAll()
    {
        ScriptCompileVerdictCache.Clear();

        for (var i = 0; i < OverflowCount; i++)
            Lookup($"// bulk {i}", static () => (true, null));

        Assert.True(ScriptCompileVerdictCache.Evictions > 0);

        // The most recently inserted entry is in the hot generation, so it must
        // still be cached. Under the old Clear()-on-overflow policy the cache could
        // be left holding a single entry after a rotation.
        var recompiled = false;
        Lookup($"// bulk {OverflowCount - 1}", () =>
        {
            recompiled = true;
            return (true, null);
        });

        Assert.False(recompiled, "the most recent entry was evicted; eviction is not retaining the hot generation");
        Assert.True(ScriptCompileVerdictCache.Count > 1,
            $"cache retained only {ScriptCompileVerdictCache.Count} entries after eviction");
    }

    [Fact]
    public void Overflow_KeepsCacheBounded()
    {
        ScriptCompileVerdictCache.Clear();

        for (var i = 0; i < OverflowCount; i++)
            Lookup($"// bounded {i}", static () => (true, null));

        // Two generations of 2048 — the same 4096 ceiling the previous policy had,
        // now reached by demotion rather than by dropping everything.
        Assert.True(ScriptCompileVerdictCache.Count <= 4096,
            $"cache grew to {ScriptCompileVerdictCache.Count} entries, exceeding its two-generation bound");
    }

    /// <summary>
    /// The bound must hold under a PROMOTION-heavy workload too, not just a
    /// pure-insert one. Promotion used to write straight into hot
    /// (<c>_hot[key] = verdict</c>) with no capacity check, so re-reading a working
    /// set bigger than one segment pulled the whole cold generation back into hot
    /// on top of what hot already held — hot alone reached 2 × SegmentCapacity and
    /// the total 3 ×, against a documented 2 ×. Pure-insert overflow never hits
    /// that path because every key is distinct.
    /// </summary>
    [Fact]
    public void PromotionOverflow_KeepsCacheBounded()
    {
        ScriptCompileVerdictCache.Clear();

        // Fill past one rotation so a large working set is sitting in cold with
        // hot already partly full.
        const int WorkingSet = 3000;
        for (var i = 0; i < WorkingSet; i++)
            Lookup($"// promo {i}", static () => (true, null));

        Assert.True(ScriptCompileVerdictCache.Evictions > 0);

        // Re-read the whole working set: every entry still in cold promotes.
        for (var i = 0; i < WorkingSet; i++)
            Lookup($"// promo {i}", static () => (true, null));

        Assert.True(ScriptCompileVerdictCache.Count <= 4096,
            $"cache grew to {ScriptCompileVerdictCache.Count} entries under promotion, exceeding its two-generation bound");
    }

    [Fact]
    public void SurfaceIsPartOfTheKey_AcrossEviction()
    {
        ScriptCompileVerdictCache.Clear();

        const string code = "// same body, two surfaces";
        Lookup(code, static () => (true, null));

        var otherSurfaceCompiled = false;
        var verdict = ScriptCompileVerdictCache.GetOrAdd("OtherSurface", code, () =>
        {
            otherSurfaceCompiled = true;
            return (false, "not valid against this surface");
        });

        // A verdict is never interchangeable across globals surfaces, and the
        // segmented cache must not weaken that.
        Assert.True(otherSurfaceCompiled);
        Assert.False(verdict.Ok);
    }
}

/// <summary>
/// Serialises every test that touches the process-wide
/// <see cref="ScriptCompileVerdictCache"/> static.
/// </summary>
[CollectionDefinition("ScriptCompileVerdictCache")]
public class ScriptCompileVerdictCacheCollection;
