using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;

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

/// <summary>
/// Process-wide cache of script compile verdicts, keyed on the pair
/// (globals surface, SHA-256 hash of the script code). The verdict (clean/compiles,
/// or the name-free error text) is a pure function of the code plus the
/// compile-time-static <c>ScriptTrustPolicy</c> AND the globals surface it was
/// compiled against, so it is sound to memoise it across the whole process: an
/// unchanged script runs the forbidden-API analysis and the Roslyn compile exactly
/// once, removing both the repeat CPU cost and the repeat compiled-assembly load.
///
/// <para>
/// The globals surface is part of the key because a verdict is NOT interchangeable
/// across surfaces: a trigger expression that compiles clean against
/// <c>TriggerCompileSurface</c> is not vetted for a <c>ScriptCompileSurface</c>
/// script body (different globals resolve different identifiers), so a code-only key
/// could return a stale "clean" for code never compiled against the caller's surface.
/// Keeping the surface in the key makes that cross-surface reuse structurally
/// impossible even as more compile surfaces gain cache entries.
/// </para>
///
/// <para>
/// The stored error is deliberately <em>name-free</em> — the caller's script name is
/// formatted into the returned message at read time, so two callers submitting the
/// same bad code each see their own name in the failure text while sharing one verdict.
/// </para>
///
/// <para>
/// <b>Eviction is segmented, never wholesale.</b> Overflow used to
/// <c>Clear()</c> the whole cache, which reads as harmless ("the verdict is cheap
/// to recompute") but is not: a recompute is a fresh Roslyn compile, and every
/// script compile loads an assembly through a NON-COLLECTIBLE
/// <c>InteractiveAssemblyLoader</c>. That leak is the entire reason this cache
/// exists, so dropping every hot entry at the 4096th distinct script re-opens it
/// for the whole working set at once. Instead the cache runs two generations: new
/// verdicts land in <c>hot</c>, a hit in <c>cold</c> is promoted back into
/// <c>hot</c>, and on overflow <c>hot</c> becomes the new <c>cold</c> and the old
/// <c>cold</c> — the half nothing has touched for a full generation — is the only
/// thing dropped. Anything actively in use is therefore retained across an
/// eviction, and the cache still cannot grow past two generations.
/// </para>
/// </summary>
public static class ScriptCompileVerdictCache
{
    /// <summary>
    /// Upper bound on entries in the hot generation. The cache holds at most
    /// <c>2 × SegmentCapacity</c> entries in total (hot + cold), preserving the
    /// previous 4096-entry ceiling. Enforced on EVERY insert into hot — fresh
    /// verdicts and cold-generation promotions alike, both of which go through
    /// <see cref="Store"/>. (Hot can momentarily exceed the bound by the number of
    /// threads that pass the pre-rotation check concurrently; the bound is a
    /// memory ceiling, not a hard invariant, and that overshoot is bounded by
    /// concurrency rather than by working-set size.)
    /// </summary>
    private const int SegmentCapacity = 2048;

    /// <summary>
    /// Guards the generation ROTATION only. Reads and single-entry writes stay on
    /// the lock-free <see cref="ConcurrentDictionary{TKey,TValue}"/> paths; the
    /// lock exists so two threads overflowing at once cannot rotate twice and
    /// discard a generation that was only one insert old.
    /// </summary>
    private static readonly object RotateGate = new();

    // Volatile: both fields are REPLACED wholesale by a rotation under
    // RotateGate, while readers on GetOrAdd/Store run lock-free. Without the
    // volatile read a reader is free to cache the reference (or observe the
    // rotation's two writes out of order) and keep serving — or worse, keep
    // inserting into — a generation the rotation has already retired.
    private static volatile ConcurrentDictionary<string, (bool Ok, string? Error)> _hot = new();
    private static volatile ConcurrentDictionary<string, (bool Ok, string? Error)> _cold = new();
    private static long _hits;
    private static long _evictions;

    /// <summary>Number of cache hits observed since the last <see cref="Clear"/>.</summary>
    public static long Hits => Interlocked.Read(ref _hits);

    /// <summary>
    /// Number of generation rotations performed. One rotation drops at most
    /// <see cref="SegmentCapacity"/> untouched entries. Exposed for diagnostics.
    /// </summary>
    public static long Evictions => Interlocked.Read(ref _evictions);

    /// <summary>Current number of cached verdicts across both generations.</summary>
    public static int Count => _hot.Count + _cold.Count;

    /// <summary>
    /// Returns the cached verdict for the pair (<paramref name="surface"/>,
    /// <paramref name="code"/>), or computes it via <paramref name="factory"/> and
    /// caches it on a miss. A hit increments <see cref="Hits"/>. The verdict's error
    /// text must be name-free — the caller formats the script name in on return.
    /// </summary>
    /// <param name="surface">
    /// The globals-surface discriminator (e.g. <c>nameof(ScriptCompileSurface)</c> or
    /// <c>nameof(TriggerCompileSurface)</c>). Part of the key so a verdict computed
    /// against one surface is never reused for another.
    /// </param>
    /// <param name="code">The script source code to look up (hashed to form the cache key).</param>
    /// <param name="factory">Computes the verdict on a cache miss.</param>
    /// <returns>The cached or newly computed compile verdict.</returns>
    public static (bool Ok, string? Error) GetOrAdd(string surface, string code, Func<(bool Ok, string? Error)> factory)
    {
        var key = surface + ":" + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(code)));

        // Snapshot the generations once: a concurrent rotation between the two
        // lookups would otherwise let an entry slip through both.
        var hot = _hot;
        var cold = _cold;

        if (hot.TryGetValue(key, out var verdict))
        {
            Interlocked.Increment(ref _hits);
            return verdict;
        }

        if (cold.TryGetValue(key, out verdict))
        {
            // Promote: the entry is in active use, so it must survive the next
            // rotation. Goes through Store (rather than a direct `_hot[key] =`)
            // so a promotion obeys SegmentCapacity like any other insert — a
            // direct write let hot grow past the segment bound whenever a working
            // set larger than SegmentCapacity was being re-read, taking the true
            // ceiling to 3 × SegmentCapacity against a documented 2 ×. Store
            // writes to the CURRENT hot generation (re-read inside, in case a
            // rotation happened since the snapshot above), which is what makes
            // this an LRU rather than a fixed-lifetime cache; a rotation
            // triggered BY this promotion still keeps the entry, because the
            // insert lands in the fresh hot generation afterwards.
            Interlocked.Increment(ref _hits);
            Store(key, verdict);
            return verdict;
        }

        verdict = factory();
        Store(key, verdict);
        return verdict;
    }

    /// <summary>
    /// Inserts a freshly computed verdict, rotating the generations first when the
    /// hot segment is full.
    /// </summary>
    private static void Store(string key, (bool Ok, string? Error) verdict)
    {
        if (_hot.Count >= SegmentCapacity)
        {
            lock (RotateGate)
            {
                // Re-check inside the lock: a racing thread may already have
                // rotated, in which case rotating again would discard a
                // generation that is one insert old.
                if (_hot.Count >= SegmentCapacity)
                {
                    _cold = _hot;
                    _hot = new ConcurrentDictionary<string, (bool Ok, string? Error)>();
                    Interlocked.Increment(ref _evictions);
                }
            }
        }

        _hot[key] = verdict;
    }

    /// <summary>Clears all cached verdicts and resets the counters. Test/diagnostic use only.</summary>
    public static void Clear()
    {
        lock (RotateGate)
        {
            _hot = new ConcurrentDictionary<string, (bool Ok, string? Error)>();
            _cold = new ConcurrentDictionary<string, (bool Ok, string? Error)>();
        }
        Interlocked.Exchange(ref _hits, 0);
        Interlocked.Exchange(ref _evictions, 0);
    }
}
