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

namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;

/// <summary>
/// Process-wide cache of site-side script <see cref="ScriptCompilationResult"/>s, keyed on
/// the globals type plus a SHA-256 hash of the script code. Unlike the TemplateEngine's
/// <c>ScriptCompileVerdictCache</c> (which caches central-validation <em>verdicts</em>), this
/// caches the full compiled result — the immutable Roslyn <c>Script&lt;object?&gt;</c> itself —
/// so the synchronous deploy gate and the <c>InstanceActor.PreStart</c> recompile share one
/// Roslyn compile per script body per process lifetime (N4).
///
/// <para>Soundness rests on three invariants:</para>
/// <list type="number">
///   <item>A compile result is a pure function of (code, globals type, compile-time-static
///   trust policy) — none of which vary at runtime for a given key.</item>
///   <item>The stored error text is name-free (Roslyn <c>Diagnostic.GetMessage()</c> /
///   <c>"Compilation exception: …"</c>); callers prefix their own script-name context, so a
///   shared cached failure renders correctly for every caller. <em>Failures are cached too</em> —
///   a bad script body fails identically for every caller.</item>
///   <item>Roslyn <c>Script&lt;T&gt;</c> is immutable and thread-safe (<c>RunAsync</c> creates
///   per-run state), so one compiled instance may be shared across actors and threads.</item>
/// </list>
///
/// <para>
/// Bounded at <see cref="MaxEntries"/> entries — deliberately smaller than the verdict cache's
/// 4096 because entries pin compiled assemblies, not just verdict strings.
/// </para>
///
/// <para><b>WP3.1 — approximate LRU replaced the overflow cliff.</b> The cache used to
/// <c>Clear()</c> wholesale on overflow. Instance scripts AND trigger expressions share this
/// cache, so on a site large enough to cross 1024 entries every overflow discarded up to 1023
/// live compiled scripts and the next deploy or Instance-Actor start paid a full recompile
/// storm — on actor threads. Now an insert at the bound evicts only the oldest
/// <see cref="EvictionBatchDivisor">⅛</see> of entries by last-access stamp: overflow costs one
/// 1024-element scan instead of 1023 future recompiles, and hot entries survive.</para>
///
/// <para>Recency is an <see cref="Interlocked"/> access sequence, not a clock: it is
/// deterministic for tests and immune to clock steps. Hits update the stamp lock-free; the
/// only lock is the (rare) eviction sweep, double-checked so concurrent inserts do not
/// stampede it.</para>
/// </summary>
internal static class SiteScriptCompileCache
{
    /// <summary>Upper bound on cached entries; crossing it evicts the oldest batch.</summary>
    internal const int MaxEntries = 1024;

    /// <summary>
    /// Fraction of the cache evicted in one sweep (⅛ = 128 entries at
    /// <see cref="MaxEntries"/>). Batching amortises the O(n) scan across many inserts, so
    /// steady-state churn does not re-scan on every single miss.
    /// </summary>
    private const int EvictionBatchDivisor = 8;

    private static readonly ConcurrentDictionary<string, CacheEntry> Cache = new();
    private static readonly object EvictionLock = new();
    private static long _hits;

    /// <summary>Monotonic access sequence; the recency stamp written onto entries.</summary>
    private static long _accessSequence;

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

    /// <summary>Current number of cached compile results.</summary>
    public static int Count => Cache.Count;

    /// <summary>
    /// Returns the cached compile result for <paramref name="code"/> against
    /// <paramref name="globalsType"/>, or computes it via <paramref name="factory"/> and caches
    /// it on a miss. A hit increments <see cref="Hits"/> and refreshes the entry's recency.
    /// Both success and failure results are cached — the error text is name-free by construction.
    /// </summary>
    /// <param name="code">The script source code to look up (hashed to form the cache key).</param>
    /// <param name="globalsType">The Roslyn globals surface the script compiles against; part of the key so identical source under different globals stays distinct.</param>
    /// <param name="factory">Computes the compile result on a cache miss.</param>
    /// <returns>The cached or newly computed compile result.</returns>
    public static ScriptCompilationResult GetOrAdd(string code, Type globalsType, Func<ScriptCompilationResult> factory)
    {
        var key = globalsType.FullName + ":" + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(code)));

        if (Cache.TryGetValue(key, out var entry))
        {
            Interlocked.Increment(ref _hits);
            Touch(entry);
            return entry.Result;
        }

        var result = factory();

        if (Cache.Count >= MaxEntries)
            EvictOldestBatch();

        Cache[key] = new CacheEntry(result) { LastAccess = NextStamp() };
        return result;
    }

    /// <summary>Clears all cached compile results and resets the hit counter.</summary>
    public static void Clear()
    {
        Cache.Clear();
        Interlocked.Exchange(ref _hits, 0);
    }

    /// <summary>Refreshes an entry's recency stamp. Lock-free — a lost race only costs accuracy, never correctness.</summary>
    private static void Touch(CacheEntry entry) => Volatile.Write(ref entry.LastAccess, NextStamp());

    private static long NextStamp() => Interlocked.Increment(ref _accessSequence);

    /// <summary>
    /// Evicts the oldest <see cref="MaxEntries"/> / <see cref="EvictionBatchDivisor"/> entries
    /// by recency stamp. Double-checked under <see cref="EvictionLock"/> so several concurrent
    /// inserts crossing the bound together perform ONE sweep rather than one each.
    /// </summary>
    private static void EvictOldestBatch()
    {
        lock (EvictionLock)
        {
            if (Cache.Count < MaxEntries) return; // another thread already swept

            var batch = Math.Max(1, MaxEntries / EvictionBatchDivisor);

            // ConcurrentDictionary.ToArray() takes an ATOMIC snapshot. Enumerating (or
            // LINQ-ing) the dictionary directly does not: LINQ's ToArray picks the
            // ICollection<KeyValuePair<,>>.CopyTo fast path, which reads Count and then
            // copies, and throws ArgumentException when a concurrent insert lands between
            // the two. The eviction lock only excludes other EVICTORS — inserts run
            // lock-free by design — so the snapshot must be the thread-safe one.
            var victims = Cache.ToArray()
                .OrderBy(kvp => Volatile.Read(ref kvp.Value.LastAccess))
                .Take(batch)
                .Select(kvp => kvp.Key)
                .ToArray();

            foreach (var victim in victims)
                Cache.TryRemove(victim, out _);
        }
    }

    /// <summary>
    /// A cached compile result plus its recency stamp. A class (not a struct) so
    /// <see cref="Touch"/> can update recency in place without replacing the dictionary
    /// value — the hot path stays a single volatile write.
    /// </summary>
    private sealed class CacheEntry(ScriptCompilationResult result)
    {
        /// <summary>The memoised compile result (success or failure).</summary>
        public ScriptCompilationResult Result { get; } = result;

        /// <summary>Access sequence number of the most recent hit; older = evicted first.</summary>
        public long LastAccess;
    }
}
