using Microsoft.CodeAnalysis;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;

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

/// <summary>
///     Factory for the compile-time sandbox every user script is built against.
///     Enforces a read-only sandbox by whitelisting only the
///     assemblies + namespaces the script API needs; no <c>System.IO</c>, no
///     <c>System.Net</c>, no <c>System.Diagnostics.Process</c>, no
///     <c>System.Reflection</c>. Attempts to reference those types in a script fail at
///     compile with a compiler error that points at the exact span — the operator sees
///     the rejection before publish, not at evaluation.
/// </summary>
/// <remarks>
///     <para>
///         Roslyn would otherwise pull in every type in the BCL transitively via
///         <c>mscorlib</c> / <c>System.Runtime</c> — this class overrides that with an
///         explicit minimal allow-list. The list is the same regardless of whether
///         <see cref="ScriptEvaluator{TContext, TResult}"/> uses the legacy
///         <c>CSharpScript</c> path or the collectible-<c>AssemblyLoadContext</c> path:
///         both go through <see cref="Build"/>.
///     </para>
///     <para>
///         Namespaces pre-imported so scripts don't have to write <c>using</c> clauses:
///         <c>System</c>, <c>System.Math</c>-style statics are reachable via
///         <see cref="Math"/>, and <c>ZB.MOM.WW.OtOpcUa.Core.Abstractions</c> so scripts
///         can name <see cref="DataValueSnapshot"/> directly.
///     </para>
///     <para>
///         The sandbox cannot prevent a script from allocating unbounded memory or
///         spinning in a tight loop — those are budget concerns, handled by the
///         per-evaluation timeout (Stream A.4) + the test-harness (Stream F.4) that lets
///         operators preview output before publishing.
///     </para>
/// </remarks>
public static class ScriptSandbox
{
    /// <summary>
    ///     Build the sandbox configuration used for every virtual-tag / alarm script.
    ///     <paramref name="contextType"/> is the concrete <see cref="ScriptContext"/>
    ///     subclass the script's <c>ctx</c> will be of — the compiler uses its assembly
    ///     to resolve <c>ctx.GetTag(...)</c> calls.
    /// </summary>
    /// <param name="contextType">The concrete script context type to use for compilation.</param>
    /// <returns>The sandbox configuration (assembly references + default imports) to compile the script against.</returns>
    public static SandboxConfig Build(Type contextType)
    {
        if (contextType is null) throw new ArgumentNullException(nameof(contextType));
        if (!typeof(ScriptContext).IsAssignableFrom(contextType))
            throw new ArgumentException(
                $"Script context type must derive from {nameof(ScriptContext)}", nameof(contextType));

        // OtOpcUa-owned assemblies — pinned by typeof(...) so they survive a rename.
        var pinnedAssemblies = new HashSet<System.Reflection.Assembly>
        {
            // Core.Abstractions — DataValueSnapshot + DriverDataType so scripts can name
            // the types they receive from ctx.GetTag.
            typeof(DataValueSnapshot).Assembly,
            // Core.Scripting.Abstractions — ScriptContext base class + Deadband static.
            // Intentionally NOT Core.Scripting (which holds ScriptEvaluator/ScriptSandbox + Roslyn):
            // keeping it out of the sandbox pin keeps Roslyn out of the globalsType assembly closure.
            typeof(ScriptContext).Assembly,
            // Serilog.ILogger — script-side logger type.
            typeof(Serilog.ILogger).Assembly,
            // Concrete context type's assembly — production contexts subclass
            // ScriptContext in Core.VirtualTags / Core.ScriptedAlarms; tests use their
            // own subclass. The globals wrapper is generic on this type so Roslyn must
            // be able to resolve it during compilation.
            contextType.Assembly,
        };

        // BCL references. We list the trusted-platform-assemblies set restricted to
        // System.* and netstandard so the synthesized wrapper can reference every BCL
        // type by FQN — including the ones we forbid (HttpClient, File, Process,
        // Registry, etc.). Letting those types resolve at compile is intentional: the
        // hard security gate is ForbiddenTypeAnalyzer in step 3 of the compile pipeline
        // (the analyzer must be the sole gate because type forwarding makes any
        // references-list-only restriction porous).
        // The references list now serves only as scoping hygiene — out-of-band BCL
        // surface (operator-authored hosting helpers, third-party packages, app code)
        // is not on the list and stays unreachable.
        var references = new List<MetadataReference>();
        foreach (var asm in pinnedAssemblies)
            references.Add(MetadataReference.CreateFromFile(asm.Location));
        foreach (var path in EnumerateBclAssemblyPaths())
            references.Add(MetadataReference.CreateFromFile(path));

        var imports = new[]
        {
            "System",
            "System.Linq",
            "ZB.MOM.WW.OtOpcUa.Core.Abstractions",
            "ZB.MOM.WW.OtOpcUa.Core.Scripting",
        };

        return new SandboxConfig(references, imports);
    }

    private static IEnumerable<string> EnumerateBclAssemblyPaths()
    {
        // The .NET host advertises the resolved runtime-shared-framework + BCL DLL set
        // via the TRUSTED_PLATFORM_ASSEMBLIES AppContext data slot. This is what the
        // ALC fallback uses when resolving assemblies, so anything in here is already
        // loadable by the host process. We restrict to System.* and netstandard to keep
        // the script's reachable surface to the BCL — anything else (Microsoft.*,
        // application code, third-party packages happening to be in the runtime store)
        // would expand the analyzer's deny-list job unnecessarily.
        var raw = (string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES");
        if (string.IsNullOrEmpty(raw))
            yield break;

        var separator = OperatingSystem.IsWindows() ? ';' : ':';
        foreach (var path in raw.Split(separator, StringSplitOptions.RemoveEmptyEntries))
        {
            var name = System.IO.Path.GetFileName(path);
            if (name.StartsWith("System.", StringComparison.Ordinal) ||
                string.Equals(name, "netstandard.dll", StringComparison.Ordinal) ||
                string.Equals(name, "mscorlib.dll", StringComparison.Ordinal) ||
                // Microsoft.Win32.Registry isn't a System.* DLL but the analyzer's
                // Microsoft.Win32 deny-list relies on the type being resolvable so it
                // can identify + reject it. Add the one
                // DLL we need rather than broadening to Microsoft.* (which would also
                // pull in compilers, build tooling, etc.).
                string.Equals(name, "Microsoft.Win32.Registry.dll", StringComparison.Ordinal))
            {
                yield return path;
            }
        }
    }
}

/// <summary>
///     Compile-time sandbox configuration. Returned by <see cref="ScriptSandbox.Build"/>;
///     consumed by <see cref="ScriptEvaluator{TContext, TResult}"/>'s manual
///     <c>CSharpCompilation</c> path.
/// </summary>
/// <param name="References">
///     Metadata references (allow-listed assemblies) the script compilation is built
///     against. Anything not in this set is unresolved at compile, which is the sandbox's
///     first line of defense — <see cref="ForbiddenTypeAnalyzer"/> is the second.
/// </param>
/// <param name="Imports">
///     Namespaces pre-imported into the wrapper compilation as <c>using</c> directives
///     so scripts can write <c>Math.Abs</c> rather than <c>System.Math.Abs</c>.
/// </param>
public sealed record SandboxConfig(
    IReadOnlyList<MetadataReference> References,
    IReadOnlyList<string> Imports);
