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

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

/// <summary>
///     Source-hash-keyed compile cache for user scripts. Roslyn compilation is the most
///     expensive step in the evaluator pipeline (5-20ms per script depending on size);
///     re-compiling on every value-change event would starve the virtual-tag engine.
///     The cache is generic on the <see cref="ScriptContext"/> subclass + result type so
///     different engines (virtual-tag / alarm-predicate / future alarm-action) each get
///     their own cache instance — there's no cross-type pollution.
/// </summary>
/// <remarks>
///     <para>
///         Concurrent-safe: <see cref="ConcurrentDictionary{TKey, TValue}"/> of
///         <see cref="Lazy{T}"/> means a miss on two threads compiles exactly once.
///         <see cref="LazyThreadSafetyMode.ExecutionAndPublication"/> guarantees other
///         threads block on the in-flight compile rather than racing to duplicate work.
///     </para>
///     <para>
///         Cache is keyed on SHA-256 of the UTF-8 bytes of the source — collision-free in
///         practice. Whitespace changes therefore miss the cache on purpose; operators
///         see re-compile time on their first evaluation after a format-only edit which
///         is rare and benign.
///     </para>
///     <para>
///         No capacity bound. Virtual-tag + alarm scripts are operator-authored and
///         bounded by config DB (typically low thousands). If that changes in v3, add an
///         LRU eviction policy — the API stays the same.
///     </para>
///     <para>
///         <b>Lifecycle:</b> compiled scripts hold a collectible
///         <see cref="System.Runtime.Loader.AssemblyLoadContext"/> per evaluator.
///         <see cref="Clear"/> disposes every materialised
///         evaluator before dropping its dictionary entry so the emitted assemblies are
///         eligible for GC immediately after a publish. <see cref="Dispose"/> drops the
///         cache itself for graceful server shutdown.
///     </para>
/// </remarks>
public sealed class CompiledScriptCache<TContext, TResult> : IDisposable
    where TContext : ScriptContext
{
    private readonly ConcurrentDictionary<string, Lazy<ScriptEvaluator<TContext, TResult>>> _cache = new();
    private bool _disposed;

    /// <summary>
    ///     Return the compiled evaluator for <paramref name="scriptSource"/>, compiling
    ///     on first sight + reusing thereafter. If the source fails to compile, the
    ///     original Roslyn / sandbox exception propagates; the cache entry is removed so
    ///     the next call retries (useful during Admin UI authoring when the operator is
    ///     still fixing syntax).
    /// </summary>
    /// <param name="scriptSource">The source code to compile or retrieve from cache.</param>
    /// <returns>The compiled script evaluator.</returns>
    public ScriptEvaluator<TContext, TResult> GetOrCompile(string scriptSource)
    {
        if (scriptSource is null) throw new ArgumentNullException(nameof(scriptSource));
        if (_disposed) throw new ObjectDisposedException(nameof(CompiledScriptCache<TContext, TResult>));

        var key = HashSource(scriptSource);
        var lazy = _cache.GetOrAdd(key, _ => new Lazy<ScriptEvaluator<TContext, TResult>>(
            () => ScriptEvaluator<TContext, TResult>.Compile(scriptSource),
            LazyThreadSafetyMode.ExecutionAndPublication));

        try
        {
            return lazy.Value;
        }
        catch
        {
            // Failed compile — evict the SPECIFIC faulted Lazy instance so a retry with
            // corrected source can succeed. The KeyValuePair<,> overload compares the
            // value reference, so if two threads race the same bad source both observe
            // the same faulted Lazy and both reach this catch, and a concurrent retry
            // re-added a fresh Lazy under the same key between the two removals, the
            // second removal does NOT evict the in-flight retry.
            _cache.TryRemove(new KeyValuePair<string, Lazy<ScriptEvaluator<TContext, TResult>>>(key, lazy));
            throw;
        }
    }

    /// <summary>Current entry count. Exposed for Admin UI diagnostics / tests.</summary>
    public int Count => _cache.Count;

    /// <summary>
    ///     Drop every cached compile. Used on config generation publish + tests.
    ///     Disposes each materialised evaluator before removing it so its collectible
    ///     <see cref="System.Runtime.Loader.AssemblyLoadContext"/> unloads and the
    ///     emitted script assembly becomes eligible for GC.
    /// </summary>
    /// <remarks>
    ///     Safe to call after <see cref="Dispose"/> — the operation is idempotent.
    ///     <see cref="Dispose"/> sets <c>_disposed = true</c> before invoking this
    ///     method (so callers see the post-Dispose guard on <see cref="GetOrCompile"/>),
    ///     but this method itself MUST run to completion so the Dispose-triggered
    ///     drain actually unloads every materialised evaluator's ALC (a previous
    ///     Clear-aborts-when-disposed guard silently skipped the entire drain on
    ///     Dispose, leaving emitted assemblies rooted).
    /// </remarks>
    public void Clear()
    {
        // Snapshot (key, value) pairs and remove with the value-scoped
        // TryRemove(KeyValuePair<,>) overload — same shape as the
        // fix in GetOrCompile's catch block. A concurrent
        // GetOrCompile re-add that hashes to the same key between our snapshot
        // and the TryRemove inserts a *different* Lazy reference; the value-
        // scoped removal sees the mismatch and leaves the fresh entry intact
        // (instead of evicting + disposing it while the concurrent caller
        // still holds it). The fresh evaluator and its ALC stay live for the
        // concurrent caller.
        foreach (var entry in _cache.ToArray())
        {
            if (_cache.TryRemove(entry))
                DisposeLazyIfMaterialised(entry.Value);
        }
    }

    /// <summary>True when the exact source has been compiled at least once + is still cached.</summary>
    /// <param name="scriptSource">The source code to check for in the cache.</param>
    /// <returns>True if the source is cached, false otherwise.</returns>
    public bool Contains(string scriptSource)
        => _cache.ContainsKey(HashSource(scriptSource));

    /// <summary>
    ///     Drop the cache and dispose every materialised evaluator. After disposal
    ///     <see cref="GetOrCompile"/> throws <see cref="ObjectDisposedException"/>.
    /// </summary>
    public void Dispose()
    {
        if (_disposed) return;
        _disposed = true;
        Clear();
    }

    private static void DisposeLazyIfMaterialised(Lazy<ScriptEvaluator<TContext, TResult>> lazy)
    {
        // IsValueCreated is false for a faulted Lazy too, so the catch in GetOrCompile
        // has already taken care of failed compiles — there's no evaluator to dispose.
        if (!lazy.IsValueCreated) return;
        try
        {
            lazy.Value.Dispose();
        }
        catch
        {
            // Dispose is best-effort here: an evaluator disposal failure would leak its
            // ALC but mustn't prevent the rest of the cache from clearing. The ALC
            // unload itself is exception-free in practice; this is defensive.
        }
    }

    private static string HashSource(string source)
    {
        var bytes = Encoding.UTF8.GetBytes(source);
        var hash = SHA256.HashData(bytes);
        return Convert.ToHexString(hash);
    }
}
