using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis;

/// <summary>
/// The single authoritative script-trust validator, fusing the two
/// previously-divergent implementations:
///
/// <list type="number">
/// <item>
/// <b>Semantic symbol analysis</b> (ported from the SiteRuntime
/// <c>ScriptCompilationService.ValidateTrustModel</c>): the script is parsed and
/// a semantic model built; every identifier / type reference / member access /
/// object creation is resolved to its symbol and the symbol's containing
/// namespace + type are checked against <see cref="ScriptTrustPolicy.ForbiddenScopes"/>.
/// This catches forbidden types regardless of how they are written —
/// <c>global::</c> prefixes, aliases, <c>using static</c>, transitively-imported
/// namespaces — because it inspects the resolved symbol, not the spelling.
/// </item>
/// <item>
/// <b>Reflection-gateway + dynamic/Activator hardening</b> (ported from the
/// InboundAPI <c>ForbiddenApiChecker.ForbiddenApiWalker</c>): a syntactic walk
/// that rejects any member access whose accessed member is in
/// <see cref="ScriptTrustPolicy.ReflectionGatewayMembers"/> (regardless of
/// receiver — so <c>typeof(string).Assembly.GetType("System.IO.File")</c> is
/// caught even though it never spells a forbidden namespace), any identifier in
/// <see cref="ScriptTrustPolicy.ForbiddenIdentifiers"/> (<c>dynamic</c>,
/// <c>Activator</c>), and forbidden <c>using</c>/qualified-name spellings.
/// </item>
/// </list>
///
/// <para>
/// Keeping BOTH passes is deliberate: the semantic pass catches alias /
/// <c>using static</c> / <c>global::</c> escapes; the syntactic pass catches
/// reflection reached through members of <em>permitted</em> types. Neither pass
/// is a true sandbox — this is best-effort defence-in-depth; genuine containment
/// needs a runtime boundary.
/// </para>
///
/// <para>
/// A forbidden type reference inside <c>nameof(...)</c> (e.g.
/// <c>nameof(System.IO.File)</c>) is deliberately flagged: this is
/// conservative/fail-safe — <c>nameof</c> is rare in scripts and a script has
/// no business naming a forbidden type even there, so we prefer fail-safe over
/// adding special-case suppression logic to a security trust boundary.
/// </para>
/// </summary>
public static class ScriptTrustValidator
{
    /// <summary>
    /// Analyses the script source and returns the deduped, sorted list of
    /// trust-model violations. An empty list means the script is clean.
    /// </summary>
    /// <param name="code">The C# script source to analyse.</param>
    /// <param name="extraReferences">
    /// Optional additional metadata references to <b>widen symbol resolution</b>
    /// for the semantic pass — e.g. the compile-surface globals assembly so a
    /// script referencing the API surface resolves cleanly. Extra references can
    /// ONLY improve resolution; they can NEVER whitelist a forbidden API. The
    /// verdict is by resolved namespace/type against
    /// <see cref="ScriptTrustPolicy.ForbiddenScopes"/>, so passing more
    /// references — even the forbidden-API assemblies themselves — can only make
    /// the verdict more accurate (more types resolve to their true namespace and
    /// are judged), never produce a false allow. Callers therefore need not (and
    /// cannot, for safety purposes) curate this set; the Central UI run gate
    /// forwards its full compilation reference surface here precisely because
    /// doing so is safe.
    /// </param>
    /// <returns>A list of trust-model violation messages; empty if the script is clean.</returns>
    public static IReadOnlyList<string> FindViolations(
        string code,
        IEnumerable<MetadataReference>? extraReferences = null)
        => FindViolations(code, ScriptTrustPolicy.AnalysisReferences, extraReferences);

    /// <summary>
    /// Overload that runs the trust analysis against an explicit base reference
    /// set instead of <see cref="ScriptTrustPolicy.AnalysisReferences"/>. The
    /// public entry point above always uses the full analysis set; this overload
    /// exists so tests can pin behaviour against the minimal TPA-fallback set
    /// (<see cref="ScriptTrustPolicy.BuildMinimalFallbackReferences"/>) and prove
    /// the degraded mode still catches the documented forbidden anchors.
    /// The two passes are otherwise identical.
    /// </summary>
    /// <param name="code">The C# script source to analyse.</param>
    /// <param name="baseReferences">The base reference set Pass 1 resolves against.</param>
    /// <param name="extraReferences">Optional additional references that only widen resolution.</param>
    /// <returns>A list of trust-model violation messages; empty if the script is clean.</returns>
    public static IReadOnlyList<string> FindViolations(
        string code,
        IReadOnlyList<MetadataReference> baseReferences,
        IEnumerable<MetadataReference>? extraReferences = null)
    {
        if (string.IsNullOrWhiteSpace(code))
            return Array.Empty<string>();

        var tree = CSharpSyntaxTree.ParseText(
            code, new CSharpParseOptions(kind: SourceCodeKind.Script));
        var root = tree.GetRoot();

        // Deduplicate so a forbidden symbol used many times is reported once but
        // distinct forbidden symbols are all reported.
        var violations = new SortedSet<string>(StringComparer.Ordinal);

        // ---- Pass 1: semantic symbol analysis (ported from SiteRuntime) ----
        // Resolve against the supplied baseReferences so EVERY type a script names
        // resolves and is judged by its true namespace — closing the
        // forbidden-type-in-allowed-namespace blind spot (e.g. a bare
        // System.Diagnostics.Process via `using System.Diagnostics;`). The public
        // entry point passes the full trusted-platform reference set; a caller on the
        // degraded/test path may instead pass the minimal anchor-enriched fallback
        // (BuildMinimalFallbackReferences()).
        var references = baseReferences.ToList();
        if (extraReferences != null)
            references.AddRange(extraReferences);

        // Apply the SAME default namespace imports the site runtime compiles the
        // real script with (ScriptTrustPolicy.DefaultImports — "using System;"
        // etc.). Without them the semantic pass under-resolves BARE identifiers
        // that the running script sees fully bound — e.g. `Environment.Exit(0)` or
        // `GC.Collect()`, whose `Environment`/`GC` resolve to the forbidden
        // System.Environment/System.GC types only when `using System;` is in
        // scope. Matching the runtime's import set closes that blind spot; it can
        // only make MORE identifiers resolve to their true (forbidden) namespace,
        // never produce a false allow.
        var compilation = CSharpCompilation.CreateScriptCompilation(
            "TrustValidation",
            tree,
            references,
            new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)
                .WithUsings(ScriptTrustPolicy.DefaultImports));

        var model = compilation.GetSemanticModel(tree);

        foreach (var node in root.DescendantNodes())
        {
            // Only inspect nodes that name a type or member; skip declarations,
            // string literals and comments entirely. Member-access and
            // qualified-name parents are evaluated as a whole, so their nested
            // name parts are skipped.
            if (node is not (SimpleNameSyntax or MemberAccessExpressionSyntax
                or QualifiedNameSyntax or ObjectCreationExpressionSyntax))
            {
                continue;
            }

            var info = model.GetSymbolInfo(node);
            var symbol = info.Symbol ?? info.CandidateSymbols.FirstOrDefault();

            // The set of fully-qualified scopes this reference touches: the
            // resolved symbol's containing namespace and type, or — when the
            // symbol could not be resolved (a type from an unreferenced
            // assembly) — the syntactic fully-qualified name written in source
            // as a safe fallback.
            var scopes = symbol != null
                ? GetSymbolScopes(symbol)
                : GetSyntacticScopes(node);
            if (scopes.Count == 0)
                continue;

            var forbidden = ScriptTrustPolicy.ForbiddenScopes.FirstOrDefault(
                f => scopes.Any(s => IsUnderScope(s, f)));
            if (forbidden == null)
                continue;

            // Allow specific exception namespaces/types (async/await, cancellation).
            if (scopes.Any(s => ScriptTrustPolicy.AllowedExceptions.Any(a => IsUnderScope(s, a))))
                continue;

            var name = symbol?.Name ?? node.ToString();
            violations.Add($"Forbidden API reference: '{forbidden}' ({scopes[0]}.{name})");
        }

        // ---- Pass 2: reflection-gateway + dynamic/Activator hardening
        //              (ported from InboundAPI) ----
        var walker = new HardeningWalker(violations);
        walker.Visit(root);

        return violations.ToList();
    }

    /// <summary>
    /// Returns the fully-qualified scopes a resolved symbol belongs to — its
    /// containing namespace and, for a type or member, the fully-qualified
    /// containing type. A bare namespace symbol is intentionally ignored: a
    /// namespace name on its own performs no action; harm requires referencing
    /// a type or a member.
    /// </summary>
    private static List<string> GetSymbolScopes(ISymbol symbol)
    {
        var scopes = new List<string>();

        switch (symbol)
        {
            case INamespaceSymbol:
                // A namespace reference alone is harmless — skip it. (This
                // avoids a false positive on the "System.Threading" qualifier
                // of the allowed "System.Threading.Tasks.Task".)
                break;
            case ITypeSymbol typeSymbol:
                scopes.Add(typeSymbol.ToDisplayString());
                if (typeSymbol.ContainingNamespace is { IsGlobalNamespace: false } typeNs)
                    scopes.Add(typeNs.ToDisplayString());
                break;
            default:
                if (symbol.ContainingType != null)
                {
                    scopes.Add(symbol.ContainingType.ToDisplayString());
                    if (symbol.ContainingType.ContainingNamespace is { IsGlobalNamespace: false } memberNs)
                        scopes.Add(memberNs.ToDisplayString());
                }
                else if (symbol.ContainingNamespace is { IsGlobalNamespace: false } ns)
                {
                    scopes.Add(ns.ToDisplayString());
                }

                break;
        }

        return scopes;
    }

    /// <summary>
    /// Fallback used when a name could not be resolved to a symbol (e.g. a type
    /// from an assembly the script is not allowed to reference). The
    /// fully-qualified name as written in source is used directly — a script
    /// that names <c>System.Net.Http.HttpClient</c> is still rejected even
    /// though that assembly is deliberately absent from the script's metadata
    /// references.
    /// </summary>
    private static List<string> GetSyntacticScopes(SyntaxNode node)
    {
        // A dotted name written in source is itself the fully-qualified scope.
        // Only consider names that actually contain a dot — bare local
        // identifiers cannot reach a forbidden namespace.
        var text = node switch
        {
            QualifiedNameSyntax q => q.ToString(),
            MemberAccessExpressionSyntax m => m.ToString(),
            _ => string.Empty,
        };

        // Strip whitespace/newlines that a multi-line member-access chain may contain.
        text = new string(text.Where(c => !char.IsWhiteSpace(c)).ToArray());

        return string.IsNullOrEmpty(text) || !text.Contains('.')
            ? []
            : [text];
    }

    /// <summary>
    /// True if <paramref name="actual"/> is exactly, or nested within,
    /// <paramref name="root"/> (e.g. "System.IO.Compression" is under
    /// "System.IO", "System.Diagnostics.Process" is under
    /// "System.Diagnostics.Process").
    /// </summary>
    private static bool IsUnderScope(string actual, string root)
        => actual.Equals(root, StringComparison.Ordinal)
           || actual.StartsWith(root + ".", StringComparison.Ordinal);

    /// <summary>
    /// True if a dotted name (a <c>using</c> import or qualified name as written)
    /// is forbidden — under a forbidden scope and not under an allowed exception.
    /// </summary>
    private static bool IsForbiddenDottedName(string dottedName)
    {
        foreach (var allowed in ScriptTrustPolicy.AllowedExceptions)
        {
            if (IsUnderScope(dottedName, allowed))
                return false;
        }

        foreach (var forbidden in ScriptTrustPolicy.ForbiddenScopes)
        {
            if (IsUnderScope(dottedName, forbidden))
                return true;
        }

        return false;
    }

    /// <summary>
    /// True if a dotted name is exactly an allowed-exception scope OR a strict
    /// prefix of one — e.g. <c>System.Threading</c> is a prefix of the allowed
    /// <c>System.Threading.Tasks</c>. Used by the hardening walker to stop
    /// descending into the namespace qualifier of an allowed type so the bare
    /// <c>System.Threading</c> qualifier of <c>System.Threading.Tasks.Task</c>
    /// is not falsely flagged (the semantic pass already ignores bare
    /// namespaces; this keeps the syntactic pass consistent).
    /// </summary>
    private static bool IsAllowedExceptionPrefix(string dottedName)
    {
        foreach (var allowed in ScriptTrustPolicy.AllowedExceptions)
        {
            if (IsUnderScope(dottedName, allowed)
                || allowed.StartsWith(dottedName + ".", StringComparison.Ordinal))
            {
                return true;
            }
        }

        return false;
    }

    /// <summary>
    /// Syntactic walker porting the InboundAPI <c>ForbiddenApiWalker</c>
    /// hardening rules — reflection-gateway members, the <c>dynamic</c> keyword,
    /// <c>Activator</c>, and forbidden <c>using</c>/qualified-name spellings.
    /// Writes into the same dedup set as the semantic pass.
    /// </summary>
    private sealed class HardeningWalker : CSharpSyntaxWalker
    {
        private readonly SortedSet<string> _violations;

        /// <summary>Initializes the walker with the shared violations set to write findings into.</summary>
        /// <param name="violations">The dedup set shared with the semantic pass.</param>
        internal HardeningWalker(SortedSet<string> violations) => _violations = violations;

        /// <summary>
        /// Strips whitespace/newlines a multi-line member-access chain may carry,
        /// so prefix matching against the dotted policy scopes is reliable.
        /// </summary>
        private static string StripWhitespace(string text)
            => new(text.Where(c => !char.IsWhiteSpace(c)).ToArray());

        /// <inheritdoc />
        public override void VisitUsingDirective(UsingDirectiveSyntax node)
        {
            if (node.Name is not null && IsForbiddenDottedName(node.Name.ToString()))
                _violations.Add($"forbidden namespace import '{node.Name}'");

            base.VisitUsingDirective(node);
        }

        /// <inheritdoc />
        public override void VisitQualifiedName(QualifiedNameSyntax node)
        {
            var text = StripWhitespace(node.ToString());

            // An allowed-exception name (or a strict prefix of one, e.g.
            // "System.Threading" under "System.Threading.Tasks") must NOT be
            // reported — but we STILL DESCEND into its children so a forbidden
            // type nested inside (e.g. a generic argument under an allowed
            // System.Threading.Tasks.TaskCompletionSource<System.Threading.Mutex>)
            // is independently visited and flagged by this pass. Suppress the
            // outer report, do not stop the walk. (Pass 2 is self-sufficient and
            // does not rely on the semantic pass to catch nested forbidden refs.)
            if (IsAllowedExceptionPrefix(text))
            {
                base.VisitQualifiedName(node);
                return;
            }

            // Check the longest qualified name; do not descend so a single
            // System.IO.File reference is reported once, not three times.
            if (IsForbiddenDottedName(text))
            {
                _violations.Add($"forbidden type reference '{text}'");
                return;
            }

            base.VisitQualifiedName(node);
        }

        /// <inheritdoc />
        public override void VisitMemberAccessExpression(MemberAccessExpressionSyntax node)
        {
            var text = StripWhitespace(node.ToString());

            // Reject reflection-gateway members regardless of the receiver — run
            // this FIRST, before any allowed-exception early-return, so a
            // gateway member hung off an allowed type
            // (e.g. System.Threading.Tasks.Task.GetType()) is still caught.
            // typeof(string).Assembly.GetType("System.IO.File") never spells a
            // forbidden namespace, but '.Assembly' and '.GetType' appear here as
            // the accessed member name.
            var memberName = node.Name.Identifier.ValueText;
            if (ScriptTrustPolicy.ReflectionGatewayMembers.Contains(memberName))
            {
                _violations.Add($"forbidden reflection member access '.{memberName}'");
                // Still descend: the receiver may contain a further violation.
            }

            // An allowed-exception member-access (or a strict prefix of one)
            // must NOT be reported — the bare forbidden-namespace qualifier of an
            // allowed type (e.g. the "System.Threading" inside
            // "System.Threading.Tasks.Task.Delay") is a false positive. But we
            // STILL DESCEND into its children so a forbidden reference nested
            // inside (e.g. a System.IO.File access in an allowed
            // System.Threading.Tasks.Task.Run(...) lambda body) is independently
            // visited and flagged by this pass. Suppress the outer report, do not
            // stop the walk. (Pass 2 is self-sufficient — it does not rely on the
            // semantic pass to catch nested forbidden refs.)
            if (IsAllowedExceptionPrefix(text))
            {
                base.VisitMemberAccessExpression(node);
                return;
            }

            // Catches fully-qualified expressions such as System.IO.File.Delete(...).
            if (IsForbiddenDottedName(text))
            {
                _violations.Add($"forbidden API access '{text}'");
                return;
            }

            base.VisitMemberAccessExpression(node);
        }

        /// <inheritdoc />
        public override void VisitIdentifierName(IdentifierNameSyntax node)
        {
            var text = node.Identifier.ValueText;

            // 'dynamic' widens late-bound member access the static walker cannot
            // see through — reject its use outright. The 'dynamic' contextual
            // keyword surfaces as an identifier name.
            if (text == "dynamic")
            {
                _violations.Add("forbidden use of the 'dynamic' keyword");
                return;
            }

            // A bare reference to a reflection entry-point type. 'Activator' has
            // no non-reflection use. ('dynamic' already returned above.)
            if (ScriptTrustPolicy.ForbiddenIdentifiers.Contains(text))
            {
                _violations.Add($"forbidden reflection type reference '{text}'");
                return;
            }

            base.VisitIdentifierName(node);
        }
    }
}
