using System.Diagnostics;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.CodeAnalysis.Text;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using ScadaLink.Commons.Interfaces.Services;

namespace ScadaLink.CentralUI.ScriptAnalysis;

/// <summary>
/// Compiles user scripts as Roslyn C# Scripting fragments against
/// <see cref="SandboxScriptHost"/> globals (template/shared) or
/// <see cref="InboundScriptHost"/> (inbound API) and surfaces diagnostics +
/// completions in the shape Monaco's provider APIs expect.
///
/// Diagnostics are cached by code hash via IMemoryCache — Monaco debounces
/// keystrokes at 500 ms but a typing-then-pausing flow can still re-issue
/// requests for the same content (window blur/focus, etc.), so the cache
/// short-circuits repeats. Completions aren't cached: position + form
/// context vary too much for the hit rate to be useful.
///
/// Beyond plain C# analysis, layers SCADA-specific extensions:
///   - In-string completion of Parameters["..."] keys (from the request's
///     DeclaredParameters), Scripts.CallShared("...") names (from
///     <see cref="ISharedScriptCatalog"/>), and Instance.CallScript("...") /
///     Children["X"].CallScript("...") / Parent.CallScript("...") names
///     (from the request's SiblingScripts / Children / Parent).
///   - Forbidden-API diagnostic for the documented script trust model,
///     resolved against the SemanticModel so user identifiers that happen
///     to share names with forbidden types (e.g. <c>var File = ...</c>)
///     do not false-positive.
/// </summary>
public class ScriptAnalysisService
{
    private static readonly ScriptOptions DefaultOptions = ScriptOptions.Default
        .AddReferences(
            typeof(object).Assembly,
            typeof(Enumerable).Assembly,
            typeof(System.Collections.Generic.Dictionary<,>).Assembly,
            typeof(System.ComponentModel.DescriptionAttribute).Assembly,
            typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly,
            typeof(Commons.Types.ScriptParameters).Assembly,
            typeof(SandboxScriptHost).Assembly)
        .AddImports(
            "System",
            "System.Collections.Generic",
            "System.Linq",
            "System.Text",
            "System.Threading.Tasks");

    // Namespaces and types banned by the script trust model.
    // Tasks live under System.Threading.Tasks and remain allowed.
    private static readonly string[] ForbiddenNamespacePrefixes =
    {
        "System.IO",
        "System.Diagnostics",
        "System.Reflection",
        "System.Net",
        "System.Threading.Thread",
        "System.Threading.Tasks.Sources",
    };

    private readonly ISharedScriptCatalog _sharedScripts;
    private readonly IMemoryCache _cache;
    private readonly IServiceProvider _services;

    public ScriptAnalysisService(
        ISharedScriptCatalog sharedScripts,
        IMemoryCache cache,
        IServiceProvider services)
    {
        _sharedScripts = sharedScripts;
        _cache = cache;
        _services = services;
    }

    /// <summary>Globals type a script of the given kind is compiled against.</summary>
    private static Type GlobalsTypeFor(ScriptKind kind) =>
        kind == ScriptKind.InboundApi ? typeof(InboundScriptHost) : typeof(SandboxScriptHost);

    /// <summary>
    /// Re-enables the nullable annotation context for an analysis compilation.
    /// Roslyn scripting defaults to a disabled nullable context, which makes any
    /// <c>?</c> annotation in a user script raise CS8632. Annotations-only keeps
    /// <c>string?</c> legal without surfacing the nullable-flow warnings.
    /// </summary>
    private static Compilation WithNullableAnnotations(Compilation compilation) =>
        compilation is CSharpCompilation cs
            ? cs.WithOptions(cs.Options.WithNullableContextOptions(NullableContextOptions.Annotations))
            : compilation;

    public DiagnoseResponse Diagnose(DiagnoseRequest request)
    {
        if (string.IsNullOrEmpty(request.Code))
            return new DiagnoseResponse(Array.Empty<DiagnosticMarker>());

        var cacheKey = "diag:" + (int)request.Kind + ":" + HashCode(request.Code);
        if (_cache.TryGetValue(cacheKey, out DiagnoseResponse? cached) && cached is not null)
            return cached;

        Script<object> script;
        try
        {
            script = CSharpScript.Create(request.Code, DefaultOptions, globalsType: GlobalsTypeFor(request.Kind));
        }
        catch (Exception ex)
        {
            var failure = new DiagnoseResponse(new[]
            {
                new DiagnosticMarker(8, 1, 1, 1, 2, ex.Message, "SCRIPT_BUILD")
            });
            return Cache(cacheKey, failure);
        }

        var compilation = WithNullableAnnotations(script.GetCompilation());
        var markers = compilation
            .GetDiagnostics()
            .Where(d => d.Severity >= DiagnosticSeverity.Info && d.Location.IsInSource)
            .Select(ToMarker)
            .ToList();

        var tree = compilation.SyntaxTrees.FirstOrDefault();
        if (tree != null)
        {
            var model = compilation.GetSemanticModel(tree);
            markers.AddRange(FindForbiddenApiUsages(tree, model));
            markers.AddRange(FindUnknownParameterKeys(tree, request.DeclaredParameters));
            markers.AddRange(FindUnknownAttributeKeys(tree, request));
            markers.AddRange(FindUnknownChildren(tree, request.Children));
        }

        return Cache(cacheKey, new DiagnoseResponse(markers));
    }

    private const int SandboxMaxTimeoutSeconds = 10;
    private const int SandboxDefaultTimeoutSeconds = 5;
    private const int SandboxMaxConsoleChars = 32_000;
    private const int SandboxMaxReturnJsonChars = 32_000;

    private const int SandboxMaxCallSharedDepth = 16;

    /// <summary>
    /// Compiles and runs a script in the central process. The globals surface
    /// depends on <see cref="SandboxRunRequest.Kind"/>: template and shared
    /// scripts run against <see cref="SandboxScriptHost"/>, inbound API method
    /// scripts against <see cref="SandboxInboundScriptHost"/>.
    /// Pure logic + the supplied Parameters always work.
    /// For the SandboxScriptHost surface, <c>Attributes</c> still throws while
    /// <c>External</c>, <c>Database</c>, and <c>Notify</c> are wired to
    /// central's real <see cref="IExternalSystemClient"/>,
    /// <see cref="IDatabaseGateway"/>, and
    /// <see cref="INotificationDeliveryService"/> — calls fire for real and
    /// have production-equivalent side effects (HTTP, SQL, SMTP).
    /// <c>CallShared</c> compiles and executes the named shared script in the
    /// same sandbox, with a recursion limit of
    /// <see cref="SandboxMaxCallSharedDepth"/>. <c>CallScript</c> still throws
    /// because a shared script has no template siblings in this context.
    /// For the SandboxInboundScriptHost surface, every <c>Route</c> call throws
    /// because cross-site routing needs a deployed site.
    /// Console.Out / Console.Error are captured per-call via an AsyncLocal
    /// scope (see <see cref="SandboxConsoleCapture"/>) so writes from the script
    /// land in the result without mutating process-global Console state — two
    /// concurrent Test Runs do not interfere with each other.
    /// </summary>
    public async Task<SandboxRunResult> RunInSandboxAsync(SandboxRunRequest request, CancellationToken ct)
    {
        if (string.IsNullOrWhiteSpace(request.Code))
        {
            return new SandboxRunResult(
                Success: false,
                ReturnValueJson: null,
                ReturnTypeName: null,
                ConsoleOutput: "",
                Error: "Script code is empty.",
                ErrorKind: SandboxErrorKind.CompileError,
                DurationMs: 0,
                Markers: Array.Empty<DiagnosticMarker>());
        }

        var timeoutSeconds = Math.Clamp(
            request.TimeoutSeconds ?? SandboxDefaultTimeoutSeconds,
            1, SandboxMaxTimeoutSeconds);

        var options = DefaultOptions.WithReferences(DefaultOptions.MetadataReferences.Concat(new[]
        {
            Microsoft.CodeAnalysis.MetadataReference.CreateFromFile(typeof(SandboxScriptHost).Assembly.Location)
        }));

        var globalsType = request.Kind == ScriptKind.InboundApi
            ? typeof(SandboxInboundScriptHost)
            : typeof(SandboxScriptHost);

        Script<object> script;
        try
        {
            script = CSharpScript.Create(request.Code, options, globalsType: globalsType);
        }
        catch (Exception ex)
        {
            return new SandboxRunResult(false, null, null, "", ex.Message,
                SandboxErrorKind.CompileError, 0,
                new[] { new DiagnosticMarker(8, 1, 1, 1, 2, ex.Message, "SCRIPT_BUILD") });
        }

        var compileDiagnostics = script.Compile(ct);
        var errorDiagnostics = compileDiagnostics
            .Where(d => d.Severity == DiagnosticSeverity.Error && d.Location.IsInSource)
            .ToList();
        if (errorDiagnostics.Count > 0)
        {
            var markers = errorDiagnostics.Select(ToMarker).ToList();
            return new SandboxRunResult(false, null, null, "",
                string.Join("\n", errorDiagnostics.Select(d => d.GetMessage())),
                SandboxErrorKind.CompileError, 0, markers);
        }

        // Trust-model gate (CentralUI-001): the documented forbidden-API set is
        // enforced HERE, before execution — not merely surfaced as an editor hint.
        // Without this, a Design-role user could run arbitrary file/process/
        // reflection/network code in the central host process.
        var trustViolations = EnforceTrustModel(script.GetCompilation());
        if (trustViolations.Count > 0)
        {
            return new SandboxRunResult(false, null, null, "",
                "Script blocked by the trust model — it references forbidden APIs "
                + "(System.IO, System.Diagnostics, System.Reflection, System.Net, threading). "
                + "See the highlighted diagnostics.",
                SandboxErrorKind.CompileError, 0, trustViolations);
        }

        var parameters = ConvertJsonParameters(request.Parameters);

        using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds));
        using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);

        // Optional instance binding: when the Test Run targets a deployed
        // instance, Instance.GetAttribute/SetAttribute/CallScript and the
        // Attributes/Children/Parent accessors route to it cross-site.
        ISandboxInstanceGateway? instanceGateway = null;
        var instanceLabel = "test-run";
        if (request.Kind != ScriptKind.InboundApi
            && !string.IsNullOrWhiteSpace(request.BindInstanceUniqueName))
        {
            var bindName = request.BindInstanceUniqueName.Trim();
            var locator = _services.GetService<IInstanceLocator>();
            var comms = _services.GetService<ScadaLink.Communication.CommunicationService>();
            if (locator == null || comms == null)
                return new SandboxRunResult(false, null, null, "",
                    "Instance binding is unavailable — cross-site communication is not configured on this node.",
                    SandboxErrorKind.SandboxLimitation, 0, null);

            var siteId = await locator.GetSiteIdForInstanceAsync(bindName, ct);
            if (siteId == null)
                return new SandboxRunResult(false, null, null, "",
                    $"Cannot bind to instance '{bindName}' — it is not deployed or has no assigned site.",
                    SandboxErrorKind.SandboxLimitation, 0, null);

            instanceGateway = new SandboxInstanceGateway(comms, siteId, bindName, linkedCts.Token);
            instanceLabel = bindName;
        }

        var externalClient = _services.GetService<IExternalSystemClient>();
        var databaseGateway = _services.GetService<IDatabaseGateway>();
        var notifyService = _services.GetService<INotificationDeliveryService>();
        var external = new SandboxExternalHelper(externalClient, instanceLabel);
        var database = new SandboxDatabaseHelper(databaseGateway, instanceLabel);
        var notify = new SandboxNotifyHelper(notifyService, instanceLabel);

        var compileCache = new Dictionary<string, Script<object>>(StringComparer.Ordinal);
        var compileCacheLock = new object();
        var depth = 0;

        Func<string, IReadOnlyDictionary<string, object?>?, CancellationToken, Task<object?>>? callSharedFunc = null;

        // Scripts.CallShared and the Instance helpers share one context across
        // the root script and any nested shared scripts — mirroring the site
        // runtime, where a shared script runs against the caller's Instance.
        var scriptsHelper = new SandboxScriptCallHelper(
            (name, ps, nestedCt) => callSharedFunc!(name, ps, nestedCt));
        var instanceContext = new SandboxInstanceContext(
            gateway: instanceGateway,
            external: external,
            database: database,
            notify: notify,
            scripts: scriptsHelper);

        callSharedFunc = async (name, ps, nestedCt) =>
        {
            if (string.IsNullOrEmpty(name))
                throw new ScriptSandboxException("Scripts.CallShared called with an empty script name.");
            if (depth >= SandboxMaxCallSharedDepth)
                throw new ScriptSandboxException(
                    $"Scripts.CallShared(\"{name}\") exceeded the sandbox recursion limit of {SandboxMaxCallSharedDepth} nested calls.");

            Script<object>? compiled;
            lock (compileCacheLock) compileCache.TryGetValue(name, out compiled);

            if (compiled == null)
            {
                var src = await _sharedScripts.GetByNameAsync(name, nestedCt);
                if (src == null)
                    throw new ScriptSandboxException(
                        $"Scripts.CallShared(\"{name}\") — no shared script with that name is registered in central.");

                Script<object> built;
                try
                {
                    built = CSharpScript.Create(src.Code, options, globalsType: typeof(SandboxScriptHost));
                }
                catch (Exception ex)
                {
                    throw new ScriptSandboxException($"Scripts.CallShared(\"{name}\") compile failed: {ex.Message}");
                }
                var nestedDiag = built.Compile(nestedCt);
                var nestedErrors = nestedDiag
                    .Where(d => d.Severity == DiagnosticSeverity.Error && d.Location.IsInSource)
                    .ToList();
                if (nestedErrors.Count > 0)
                    throw new ScriptSandboxException(
                        $"Scripts.CallShared(\"{name}\") compile failed: {string.Join("; ", nestedErrors.Select(d => d.GetMessage()))}");

                // Trust-model gate (CentralUI-001) — a nested shared script runs
                // arbitrary code too, so it must clear the same forbidden-API gate.
                if (EnforceTrustModel(built.GetCompilation()).Count > 0)
                    throw new ScriptSandboxException(
                        $"Scripts.CallShared(\"{name}\") is blocked by the script trust model — "
                        + "the shared script references forbidden APIs.");

                lock (compileCacheLock)
                {
                    if (!compileCache.TryGetValue(name, out compiled))
                    {
                        compileCache[name] = built;
                        compiled = built;
                    }
                }
            }

            var nestedHost = new SandboxScriptHost
            {
                Parameters = new Commons.Types.ScriptParameters(ps ?? new Dictionary<string, object?>()),
                CancellationToken = nestedCt,
                Instance = instanceContext,
            };

            Interlocked.Increment(ref depth);
            try
            {
                var nestedState = await compiled!.RunAsync(nestedHost, nestedCt).ConfigureAwait(false);
                return nestedState.ReturnValue;
            }
            finally
            {
                Interlocked.Decrement(ref depth);
            }
        };

        // Inbound API scripts see a different globals surface (Parameters +
        // Route); template and shared scripts see the SandboxScriptHost surface
        // mirroring the site runtime's ScriptGlobals.
        object host = request.Kind == ScriptKind.InboundApi
            ? new SandboxInboundScriptHost
            {
                Parameters = new Commons.Types.ScriptParameters(parameters),
                CancellationToken = linkedCts.Token,
            }
            : new SandboxScriptHost
            {
                Parameters = new Commons.Types.ScriptParameters(parameters),
                CancellationToken = linkedCts.Token,
                Instance = instanceContext,
            };

        // Console capture is routed per-call via an AsyncLocal scope (CentralUI-003).
        // Console.Out is process-global, so it must NOT be redirected per run — two
        // concurrent Test Runs would interleave output and the first to finish would
        // restore Console.Out while the other is still writing. SandboxConsoleCapture
        // installs routing writers once and scopes capture to this call-tree only.
        var (captureOut, captureError) = SandboxConsoleCapture.Install();
        var captured = new StringWriter();
        using var outScope = captureOut.BeginCapture(captured);
        using var errorScope = captureError.BeginCapture(captured);

        var stopwatch = Stopwatch.StartNew();
        try
        {
            // Run on a thread-pool thread with no SynchronizationContext: a
            // bound script's Instance.SetAttribute / Attributes[...] block
            // synchronously on cross-site I/O (the API surface is sync by
            // contract), which would deadlock against the Blazor circuit's
            // captured context if the script ran inline.
            var state = await Task.Run(
                () => script.RunAsync(host, linkedCts.Token), linkedCts.Token)
                .ConfigureAwait(false);
            stopwatch.Stop();
            var (returnJson, returnType) = SerializeReturn(state.ReturnValue);
            return new SandboxRunResult(
                Success: true,
                ReturnValueJson: returnJson,
                ReturnTypeName: returnType,
                ConsoleOutput: TruncateConsole(captured.ToString()),
                Error: null,
                ErrorKind: SandboxErrorKind.None,
                DurationMs: stopwatch.ElapsedMilliseconds,
                Markers: null);
        }
        catch (ScriptSandboxException sandboxEx)
        {
            stopwatch.Stop();
            return new SandboxRunResult(false, null, null,
                TruncateConsole(captured.ToString()), sandboxEx.Message,
                SandboxErrorKind.SandboxLimitation, stopwatch.ElapsedMilliseconds, null);
        }
        catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
        {
            stopwatch.Stop();
            return new SandboxRunResult(false, null, null,
                TruncateConsole(captured.ToString()),
                $"Script execution exceeded the {timeoutSeconds}-second sandbox timeout.",
                SandboxErrorKind.Timeout, stopwatch.ElapsedMilliseconds, null);
        }
        catch (Exception ex)
        {
            stopwatch.Stop();
            var inner = ex is Microsoft.CodeAnalysis.Scripting.CompilationErrorException ? ex : (ex.InnerException ?? ex);
            if (inner is ScriptSandboxException sx)
            {
                return new SandboxRunResult(false, null, null,
                    TruncateConsole(captured.ToString()), sx.Message,
                    SandboxErrorKind.SandboxLimitation, stopwatch.ElapsedMilliseconds, null);
            }
            return new SandboxRunResult(false, null, null,
                TruncateConsole(captured.ToString()),
                $"{inner.GetType().Name}: {inner.Message}",
                SandboxErrorKind.RuntimeError, stopwatch.ElapsedMilliseconds, null);
        }
        // outScope / errorScope are disposed by their `using` declarations when the
        // method returns, restoring the previous capture scope on this call-tree
        // without touching process-global Console state.
    }

    private static Dictionary<string, object?> ConvertJsonParameters(
        Dictionary<string, JsonElement>? parameters)
    {
        var result = new Dictionary<string, object?>(StringComparer.Ordinal);
        if (parameters == null) return result;
        foreach (var (key, value) in parameters)
        {
            result[key] = JsonElementToObject(value);
        }
        return result;
    }

    private static object? JsonElementToObject(JsonElement element)
    {
        return element.ValueKind switch
        {
            JsonValueKind.String => element.GetString(),
            JsonValueKind.Number => element.TryGetInt64(out var i) ? (object)i : element.GetDouble(),
            JsonValueKind.True => true,
            JsonValueKind.False => false,
            JsonValueKind.Null => null,
            JsonValueKind.Undefined => null,
            JsonValueKind.Array => element.EnumerateArray().Select(JsonElementToObject).ToList(),
            JsonValueKind.Object => element.EnumerateObject()
                .ToDictionary(p => p.Name, p => JsonElementToObject(p.Value)),
            _ => null
        };
    }

    private static (string Json, string TypeName) SerializeReturn(object? value)
    {
        if (value == null) return ("null", "null");
        var typeName = value.GetType().Name;
        try
        {
            var json = JsonSerializer.Serialize(value, new JsonSerializerOptions { WriteIndented = true });
            if (json.Length > SandboxMaxReturnJsonChars)
                json = json[..SandboxMaxReturnJsonChars] + "\n… (truncated)";
            return (json, typeName);
        }
        catch (Exception ex)
        {
            return ($"\"<unserializable: {ex.Message}>\"", typeName);
        }
    }

    private static string TruncateConsole(string text)
    {
        if (text.Length <= SandboxMaxConsoleChars) return text;
        return text[..SandboxMaxConsoleChars] + "\n… (truncated)";
    }

    private DiagnoseResponse Cache(string key, DiagnoseResponse value)
    {
        _cache.Set(key, value, new MemoryCacheEntryOptions
        {
            Size = 1,
            SlidingExpiration = TimeSpan.FromMinutes(5)
        });
        return value;
    }

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

    public async Task<CompletionsResponse> CompleteAsync(CompletionsRequest request)
    {
        if (string.IsNullOrEmpty(request.CodeText))
            return new CompletionsResponse(Array.Empty<CompletionItem>());

        Script<object> script;
        try
        {
            script = CSharpScript.Create(request.CodeText, DefaultOptions, globalsType: GlobalsTypeFor(request.Kind));
        }
        catch
        {
            return new CompletionsResponse(Array.Empty<CompletionItem>());
        }

        var compilation = script.GetCompilation();
        var tree = compilation.SyntaxTrees.FirstOrDefault();
        if (tree == null) return new CompletionsResponse(Array.Empty<CompletionItem>());

        var semanticModel = compilation.GetSemanticModel(tree);
        var position = PositionToOffset(request.CodeText, request.Line, request.Column);
        position = Math.Clamp(position, 0, request.CodeText.Length);

        var root = tree.GetRoot();
        var token = root.FindToken(Math.Max(0, position - 1));

        // SCADA-specific string-literal completions take priority over plain C#
        // because they're the actually useful suggestions inside those literals.
        var stringMatches = await TryStringLiteralCompletions(token, request);
        if (stringMatches != null)
            return new CompletionsResponse(stringMatches);

        // Dot completion: members of the type on the left of the dot.
        var dotMembers = TryGetDotMembers(token, semanticModel);
        if (dotMembers != null)
            return new CompletionsResponse(dotMembers);

        // General completion: in-scope symbols at position.
        var scoped = semanticModel.LookupSymbols(position)
            .Where(s => !s.IsImplicitlyDeclared && !string.IsNullOrEmpty(s.Name))
            .GroupBy(s => s.Name)
            .Select(g => g.First())
            .Select(ToCompletionItem)
            .Take(200)
            .ToList();

        return new CompletionsResponse(scoped);
    }

    private async Task<List<CompletionItem>?> TryStringLiteralCompletions(
        SyntaxToken token, CompletionsRequest request)
    {
        // The token at the cursor must be (or be adjacent to) a string literal.
        var literal = token.IsKind(SyntaxKind.StringLiteralToken)
            ? token
            : token.GetPreviousToken().IsKind(SyntaxKind.StringLiteralToken)
                ? token.GetPreviousToken()
                : default;
        if (literal == default) return null;

        // Token tree shape: StringLiteralToken → LiteralExpression → Argument →
        // (ArgumentList | BracketedArgumentList) → invocation or element-access.
        var argument = literal.Parent?.Parent as ArgumentSyntax;
        var argumentList = argument?.Parent;
        var owner = argumentList?.Parent;

        // Parameters["..."]
        if (owner is ElementAccessExpressionSyntax elem
            && elem.Expression is IdentifierNameSyntax id
            && id.Identifier.ValueText == "Parameters")
        {
            return (request.DeclaredParameters ?? Array.Empty<string>())
                .Distinct()
                .Select(n => new CompletionItem(n, n, "declared parameter", "Variable"))
                .ToList();
        }

        // Attributes["..."] / Children["X"].Attributes["..."] / Parent.Attributes["..."]
        if (owner is ElementAccessExpressionSyntax attrElem)
        {
            var ctx = ClassifyAttributeContext(
                attrElem,
                request.Children ?? Array.Empty<CompositionContext>(),
                request.Parent);
            if (ctx.Kind != AttributeContextKind.None)
            {
                IReadOnlyList<AttributeShape> source = ctx.Kind switch
                {
                    AttributeContextKind.Self => request.SelfAttributes ?? Array.Empty<AttributeShape>(),
                    AttributeContextKind.Child => ctx.Composition!.Attributes,
                    AttributeContextKind.Parent => request.Parent?.Attributes ?? Array.Empty<AttributeShape>(),
                    _ => Array.Empty<AttributeShape>()
                };
                var label = ctx.Kind switch
                {
                    AttributeContextKind.Self => "attribute",
                    AttributeContextKind.Child => $"attribute on {ctx.Composition!.Name}",
                    AttributeContextKind.Parent => "parent attribute",
                    _ => "attribute"
                };
                return source.Select(a =>
                    new CompletionItem(a.Name, a.Name, $"{label}: {a.Type}", "Field")).ToList();
            }

            // Children["..."] → suggest composition names
            if (attrElem.Expression is IdentifierNameSyntax cid && cid.Identifier.ValueText == "Children")
            {
                return (request.Children ?? Array.Empty<CompositionContext>())
                    .Select(c => new CompletionItem(c.Name, c.Name, "composition", "Class"))
                    .ToList();
            }
        }

        // Scripts.CallShared("...") / Instance.CallScript("...") /
        // Children["X"].CallScript("...") / Parent.CallScript("...")
        if (owner is InvocationExpressionSyntax inv)
        {
            var call = ClassifyScriptCall(inv);
            switch (call.Kind)
            {
                case ScriptCallKind.Shared:
                {
                    var shapes = await _sharedScripts.GetShapesAsync();
                    return shapes.Select(s => MakeCallCompletion(s, CallDetail(call))).ToList();
                }
                case ScriptCallKind.Sibling:
                    return (request.SiblingScripts ?? Array.Empty<ScriptShape>())
                        .Select(s => MakeCallCompletion(s, CallDetail(call))).ToList();
                case ScriptCallKind.Parent:
                    return (request.Parent?.Scripts ?? Array.Empty<ScriptShape>())
                        .Select(s => MakeCallCompletion(s, CallDetail(call))).ToList();
                case ScriptCallKind.Child:
                {
                    var comp = (request.Children ?? Array.Empty<CompositionContext>())
                        .FirstOrDefault(c => c.Name == call.CompositionName);
                    return comp != null
                        ? comp.Scripts.Select(s => MakeCallCompletion(s, CallDetail(call))).ToList()
                        : new List<CompletionItem>();
                }
            }
        }

        return null;
    }

    /// <summary>
    /// Builds a Monaco snippet that fills the call after the name, e.g.
    /// <c>Greet", new { name = ${1:name}, count = ${2:count} })</c>. The JS
    /// provider extends the completion range over the auto-closed <c>")</c> if
    /// Monaco inserted one, so the snippet replaces the rest of the call cleanly.
    /// </summary>
    private static CompletionItem MakeCallCompletion(ScriptShape shape, string detail)
    {
        // The runtime call API takes the arguments as an anonymous object; the
        // snippet emits one member per declared parameter.
        string insertText;
        const int insertAsSnippet = 4;
        if (shape.Parameters.Count == 0)
        {
            insertText = shape.Name + "\")";
        }
        else
        {
            var entries = string.Join(", ", shape.Parameters.Select((p, i) =>
                $"{p.Name} = ${{{i + 1}:{p.Name}}}"));
            insertText = $"{shape.Name}\", new {{ {entries} }})";
        }
        var paramList = string.Join(", ", shape.Parameters.Select(p => $"{p.Name}: {p.Type}"));
        var returnType = shape.ReturnType ?? "void";
        return new CompletionItem(
            Label: shape.Name,
            InsertText: insertText,
            Detail: $"{detail} ({paramList}) -> {returnType}",
            Kind: "Method",
            InsertTextRules: insertAsSnippet);
    }

    public FormatResponse Format(FormatRequest request)
    {
        if (string.IsNullOrEmpty(request.Code))
            return new FormatResponse(request.Code);
        try
        {
            var tree = CSharpSyntaxTree.ParseText(
                request.Code,
                new CSharpParseOptions(LanguageVersion.Latest, kind: SourceCodeKind.Script));
            // NormalizeWhitespace produces canonical layout (indentation + line
            // breaks). Formatter.Format alone with an empty workspace only
            // normalizes inter-token spacing — it won't split crammed lines.
            var formatted = tree.GetRoot().NormalizeWhitespace(indentation: "    ", eol: "\n");
            return new FormatResponse(formatted.ToFullString());
        }
        catch
        {
            return new FormatResponse(request.Code);
        }
    }

    /// <summary>
    /// Parameter-name inlay hints are obsolete under the runtime call API:
    /// Scripts.CallShared / Instance.CallScript pass arguments as an explicit
    /// <c>IReadOnlyDictionary</c> literal (<c>{ ["p"] = … }</c>), which is
    /// already self-labelling — there are no positional arguments to annotate.
    /// </summary>
    public InlayHintsResponse InlayHints(InlayHintsRequest request) =>
        new(Array.Empty<InlayHint>());

    public async Task<HoverResponse> Hover(HoverRequest request)
    {
        var script = TryParse(request.CodeText);
        if (script == null) return new HoverResponse(null);
        var (tree, _) = script.Value;
        var position = PositionToOffset(request.CodeText, request.Line, request.Column);
        position = Math.Clamp(position, 0, request.CodeText.Length);

        var root = tree.GetRoot();
        var token = root.FindToken(Math.Max(0, position - 1));
        if (!token.IsKind(SyntaxKind.StringLiteralToken)) return new HoverResponse(null);

        var literalNode = token.Parent as LiteralExpressionSyntax;
        var argument = literalNode?.Parent as ArgumentSyntax;
        var argumentList = argument?.Parent;
        var owner = argumentList?.Parent;

        // Parameters["name"] → show declared type
        if (owner is ElementAccessExpressionSyntax elem
            && elem.Expression is IdentifierNameSyntax pid
            && pid.Identifier.ValueText == "Parameters")
        {
            var key = token.ValueText;
            var p = request.DeclaredParameters?.FirstOrDefault(x => x.Name == key);
            if (p != null)
            {
                var req = p.Required ? "" : "?";
                return new HoverResponse(
                    $"**parameter** `{p.Name}: {p.Type}{req}`");
            }
            return new HoverResponse(null);
        }

        if (owner is not InvocationExpressionSyntax inv) return new HoverResponse(null);

        var call = ClassifyScriptCall(inv);
        if (call.Kind == ScriptCallKind.None) return new HoverResponse(null);
        var rawName = token.ValueText;
        if (string.IsNullOrEmpty(rawName)) return new HoverResponse(null);

        var shape = await ResolveCalledShape(
            call, rawName, request.SiblingScripts, request.Children, request.Parent);
        if (shape == null) return new HoverResponse(null);
        return new HoverResponse(FormatHover(shape, call));
    }

    public async Task<SignatureHelpResponse> SignatureHelp(SignatureHelpRequest request)
    {
        var empty = new SignatureHelpResponse(null, null, 0);
        var script = TryParse(request.CodeText);
        if (script == null) return empty;
        var (tree, _) = script.Value;
        var position = PositionToOffset(request.CodeText, request.Line, request.Column);
        position = Math.Clamp(position, 0, request.CodeText.Length);

        var root = tree.GetRoot();
        var token = root.FindToken(Math.Max(0, position - 1));

        // Walk up to the nearest enclosing InvocationExpression. Don't require
        // ArgumentList.Span to strictly contain the cursor — for an incomplete
        // call like CallScript("Calc", 1, ) the span ends before trailing
        // whitespace, so a strict contains-check would miss it.
        InvocationExpressionSyntax? inv = null;
        for (var node = token.Parent; node != null; node = node.Parent)
        {
            if (node is InvocationExpressionSyntax candidate)
            {
                inv = candidate;
                break;
            }
        }
        if (inv == null) return empty;

        var call = ClassifyScriptCall(inv);
        if (call.Kind == ScriptCallKind.None) return empty;

        // First argument is the name literal; pull it out.
        if (inv.ArgumentList.Arguments.Count < 1) return empty;
        var nameArg = inv.ArgumentList.Arguments[0].Expression as LiteralExpressionSyntax;
        var scriptName = nameArg?.Token.ValueText ?? "";

        var shape = await ResolveCalledShape(
            call, scriptName, request.SiblingScripts, request.Children, request.Parent);
        if (shape == null) return empty;

        var paramLabels = shape.Parameters.Select(p => $"{p.Name}: {p.Type}").ToList();
        var label = $"{CallLabel(call)}(\"{shape.Name}\"" +
            (paramLabels.Count > 0 ? ", " + string.Join(", ", paramLabels) : "") + ")";

        // ActiveParameter: count commas in ArgumentList before the cursor; subtract 1 because
        // the first arg is the name literal.
        int activeIndex = 0;
        foreach (var arg in inv.ArgumentList.Arguments)
        {
            if (arg.Span.End < position) activeIndex++;
            else break;
        }
        activeIndex = Math.Clamp(activeIndex - 1, 0, Math.Max(0, paramLabels.Count - 1));

        return new SignatureHelpResponse(
            Label: label,
            Parameters: paramLabels
                .Select((lbl, i) => new SignatureHelpParameter(lbl, shape.Parameters[i].Required ? null : "optional"))
                .ToList(),
            ActiveParameter: activeIndex);
    }

    private (SyntaxTree tree, Compilation compilation)? TryParse(string code)
    {
        if (string.IsNullOrEmpty(code)) return null;
        try
        {
            var s = CSharpScript.Create(code, DefaultOptions, globalsType: typeof(SandboxScriptHost));
            var compilation = s.GetCompilation();
            var tree = compilation.SyntaxTrees.FirstOrDefault();
            return tree == null ? null : (tree, compilation);
        }
        catch
        {
            return null;
        }
    }

    private static string FormatHover(ScriptShape shape, ScriptCallInfo call)
    {
        var ps = shape.Parameters.Count == 0
            ? "(no parameters)"
            : string.Join(", ", shape.Parameters.Select(p => $"{p.Name}: {p.Type}{(p.Required ? "" : "?")}"));
        var rt = shape.ReturnType ?? "void";
        return $"**{CallDetail(call)}** `{shape.Name}`\n\n```\n{shape.Name}({ps}): {rt}\n```";
    }

    private static List<CompletionItem>? TryGetDotMembers(SyntaxToken token, SemanticModel model)
    {
        var memberAccess = token.Parent as MemberAccessExpressionSyntax
            ?? token.GetPreviousToken().Parent as MemberAccessExpressionSyntax;
        if (memberAccess == null) return null;

        var typeInfo = model.GetTypeInfo(memberAccess.Expression);
        var type = typeInfo.Type ?? typeInfo.ConvertedType;
        if (type == null) return null;

        return type.GetMembers()
            .Where(m => m.CanBeReferencedByName && !m.IsImplicitlyDeclared)
            .Where(m => m.DeclaredAccessibility == Accessibility.Public || m.DeclaredAccessibility == Accessibility.NotApplicable)
            .GroupBy(m => m.Name)
            .Select(g => g.First())
            .Select(ToCompletionItem)
            .Take(200)
            .ToList();
    }

    private IEnumerable<DiagnosticMarker> FindUnknownParameterKeys(SyntaxTree tree, IReadOnlyList<string>? declared)
    {
        if (declared == null) yield break;
        var declaredSet = new HashSet<string>(declared, StringComparer.Ordinal);
        var root = tree.GetRoot();

        foreach (var elem in root.DescendantNodes().OfType<ElementAccessExpressionSyntax>())
        {
            if (elem.Expression is not IdentifierNameSyntax id || id.Identifier.ValueText != "Parameters")
                continue;
            if (elem.ArgumentList.Arguments.Count != 1) continue;
            if (elem.ArgumentList.Arguments[0].Expression is not LiteralExpressionSyntax lit) continue;
            if (!lit.IsKind(SyntaxKind.StringLiteralExpression)) continue;
            var key = lit.Token.ValueText;
            if (string.IsNullOrEmpty(key) || declaredSet.Contains(key)) continue;

            var span = lit.GetLocation().GetLineSpan().Span;
            yield return new DiagnosticMarker(
                Severity: 4,
                StartLineNumber: span.Start.Line + 1,
                StartColumn: span.Start.Character + 1,
                EndLineNumber: span.End.Line + 1,
                EndColumn: span.End.Character + 1,
                Message: $"Parameter '{key}' is not declared on this script.",
                Code: "SCADA003");
        }
    }

    private enum ScriptCallKind { None, Shared, Sibling, Child, Parent }

    /// <summary>A classified script-call invocation: which kind, and (for a child) the composition name.</summary>
    private readonly record struct ScriptCallInfo(ScriptCallKind Kind, string? CompositionName);

    /// <summary>
    /// Classifies an invocation against the runtime call surface:
    /// <c>Scripts.CallShared(...)</c>, <c>Instance.CallScript(...)</c>,
    /// <c>Children["X"].CallScript(...)</c>, and <c>Parent.CallScript(...)</c>.
    /// The first argument of each is the called script's name literal.
    /// </summary>
    private static ScriptCallInfo ClassifyScriptCall(InvocationExpressionSyntax inv)
    {
        if (inv.Expression is not MemberAccessExpressionSyntax ma)
            return new ScriptCallInfo(ScriptCallKind.None, null);

        var method = ma.Name.Identifier.ValueText;

        if (method == "CallShared"
            && ma.Expression is IdentifierNameSyntax sid && sid.Identifier.ValueText == "Scripts")
            return new ScriptCallInfo(ScriptCallKind.Shared, null);

        if (method == "CallScript")
        {
            if (ma.Expression is IdentifierNameSyntax iid)
            {
                if (iid.Identifier.ValueText == "Instance")
                    return new ScriptCallInfo(ScriptCallKind.Sibling, null);
                if (iid.Identifier.ValueText == "Parent")
                    return new ScriptCallInfo(ScriptCallKind.Parent, null);
            }
            if (ma.Expression is ElementAccessExpressionSyntax childElem
                && childElem.Expression is IdentifierNameSyntax cid && cid.Identifier.ValueText == "Children"
                && childElem.ArgumentList.Arguments.Count == 1
                && childElem.ArgumentList.Arguments[0].Expression is LiteralExpressionSyntax cLit
                && cLit.IsKind(SyntaxKind.StringLiteralExpression))
                return new ScriptCallInfo(ScriptCallKind.Child, cLit.Token.ValueText);
        }

        return new ScriptCallInfo(ScriptCallKind.None, null);
    }

    /// <summary>Human-readable call expression, e.g. <c>Scripts.CallShared</c>.</summary>
    private static string CallLabel(ScriptCallInfo call) => call.Kind switch
    {
        ScriptCallKind.Shared => "Scripts.CallShared",
        ScriptCallKind.Sibling => "Instance.CallScript",
        ScriptCallKind.Parent => "Parent.CallScript",
        ScriptCallKind.Child => $"Children[\"{call.CompositionName}\"].CallScript",
        _ => "call"
    };

    /// <summary>Short description of what the call targets, for completions/hover.</summary>
    private static string CallDetail(ScriptCallInfo call) => call.Kind switch
    {
        ScriptCallKind.Shared => "shared script",
        ScriptCallKind.Sibling => "sibling script",
        ScriptCallKind.Parent => "parent script",
        ScriptCallKind.Child => $"script on {call.CompositionName}",
        _ => "script"
    };

    /// <summary>
    /// Resolves the called script's shape from the metadata in scope for its kind.
    /// CentralUI-013: the shared-script catalog is awaited rather than blocked on
    /// with <c>.GetAwaiter().GetResult()</c>, so this method is async — and
    /// <see cref="Hover"/> / <see cref="SignatureHelp"/> are async with it.
    /// </summary>
    private async Task<ScriptShape?> ResolveCalledShape(
        ScriptCallInfo call,
        string scriptName,
        IReadOnlyList<ScriptShape>? siblings,
        IReadOnlyList<CompositionContext>? children,
        CompositionContext? parent)
    {
        switch (call.Kind)
        {
            case ScriptCallKind.Shared:
                var shapes = await _sharedScripts.GetShapesAsync();
                return shapes.FirstOrDefault(s => s.Name == scriptName);
            case ScriptCallKind.Sibling:
                return siblings?.FirstOrDefault(s => s.Name == scriptName);
            case ScriptCallKind.Parent:
                return parent?.Scripts.FirstOrDefault(s => s.Name == scriptName);
            case ScriptCallKind.Child:
                return children?.FirstOrDefault(c => c.Name == call.CompositionName)
                    ?.Scripts.FirstOrDefault(s => s.Name == scriptName);
            default:
                return null;
        }
    }

    /// <summary>
    /// SCADA006 — flag <c>Attributes["typo"]</c>,
    /// <c>Children["X"].Attributes["typo"]</c>, and
    /// <c>Parent.Attributes["typo"]</c> when the literal key isn't declared
    /// at the relevant scope. Also SCADA007 — flag <c>Children["Unknown"]</c>
    /// when the composition name isn't declared on the form.
    /// </summary>
    private IEnumerable<DiagnosticMarker> FindUnknownAttributeKeys(SyntaxTree tree, DiagnoseRequest request)
    {
        var root = tree.GetRoot();
        var selfAttrs = request.SelfAttributes ?? Array.Empty<AttributeShape>();
        var children = request.Children ?? Array.Empty<CompositionContext>();
        var parent = request.Parent;

        foreach (var elem in root.DescendantNodes().OfType<ElementAccessExpressionSyntax>())
        {
            if (elem.ArgumentList.Arguments.Count != 1) continue;
            if (elem.ArgumentList.Arguments[0].Expression is not LiteralExpressionSyntax lit) continue;
            if (!lit.IsKind(SyntaxKind.StringLiteralExpression)) continue;
            var key = lit.Token.ValueText;
            if (string.IsNullOrEmpty(key)) continue;

            var ctx = ClassifyAttributeContext(elem, children, parent);
            if (ctx.Kind == AttributeContextKind.None) continue;

            IReadOnlyList<AttributeShape> known = ctx.Kind switch
            {
                AttributeContextKind.Self => selfAttrs,
                AttributeContextKind.Child => ctx.Composition!.Attributes,
                AttributeContextKind.Parent => parent?.Attributes ?? Array.Empty<AttributeShape>(),
                _ => Array.Empty<AttributeShape>()
            };
            if (known.Count == 0) continue; // No metadata — don't false-alarm.
            if (known.Any(a => a.Name == key)) continue;

            var span = lit.GetLocation().GetLineSpan().Span;
            var scopeLabel = ctx.Kind switch
            {
                AttributeContextKind.Self => "this template",
                AttributeContextKind.Child => $"child composition '{ctx.Composition!.Name}'",
                AttributeContextKind.Parent => "the parent",
                _ => "unknown"
            };
            yield return new DiagnosticMarker(
                Severity: 4,
                StartLineNumber: span.Start.Line + 1,
                StartColumn: span.Start.Character + 1,
                EndLineNumber: span.End.Line + 1,
                EndColumn: span.End.Character + 1,
                Message: $"Attribute '{key}' is not declared on {scopeLabel}.",
                Code: "SCADA006");
        }
    }

    /// <summary>SCADA007 — <c>Children["UnknownComposition"]</c>.</summary>
    private static IEnumerable<DiagnosticMarker> FindUnknownChildren(SyntaxTree tree, IReadOnlyList<CompositionContext>? children)
    {
        var known = (children ?? Array.Empty<CompositionContext>())
            .Select(c => c.Name)
            .ToHashSet(StringComparer.Ordinal);
        if (known.Count == 0) yield break;

        var root = tree.GetRoot();
        foreach (var elem in root.DescendantNodes().OfType<ElementAccessExpressionSyntax>())
        {
            if (elem.Expression is not IdentifierNameSyntax id || id.Identifier.ValueText != "Children")
                continue;
            if (elem.ArgumentList.Arguments.Count != 1) continue;
            if (elem.ArgumentList.Arguments[0].Expression is not LiteralExpressionSyntax lit) continue;
            if (!lit.IsKind(SyntaxKind.StringLiteralExpression)) continue;
            var key = lit.Token.ValueText;
            if (string.IsNullOrEmpty(key) || known.Contains(key)) continue;

            var span = lit.GetLocation().GetLineSpan().Span;
            yield return new DiagnosticMarker(
                Severity: 4,
                StartLineNumber: span.Start.Line + 1,
                StartColumn: span.Start.Character + 1,
                EndLineNumber: span.End.Line + 1,
                EndColumn: span.End.Character + 1,
                Message: $"Composition '{key}' is not declared on this template.",
                Code: "SCADA007");
        }
    }

    private enum AttributeContextKind { None, Self, Child, Parent }
    private record AttributeContext(AttributeContextKind Kind, CompositionContext? Composition);

    /// <summary>
    /// Classifies an element-access expression as one of the scope-aware
    /// attribute contexts. Recognized shapes:
    ///   Attributes["..."]                           Self
    ///   Children["X"].Attributes["..."]             Child (composition X)
    ///   Parent.Attributes["..."]                    Parent
    /// </summary>
    private static AttributeContext ClassifyAttributeContext(
        ElementAccessExpressionSyntax elem,
        IReadOnlyList<CompositionContext> children,
        CompositionContext? parent)
    {
        // Attributes[".."]
        if (elem.Expression is IdentifierNameSyntax id && id.Identifier.ValueText == "Attributes")
            return new(AttributeContextKind.Self, null);

        // (something).Attributes[".."]
        if (elem.Expression is MemberAccessExpressionSyntax ma && ma.Name.Identifier.ValueText == "Attributes")
        {
            // Children["X"].Attributes[".."]
            if (ma.Expression is ElementAccessExpressionSyntax childElem
                && childElem.Expression is IdentifierNameSyntax cid
                && cid.Identifier.ValueText == "Children"
                && childElem.ArgumentList.Arguments.Count == 1
                && childElem.ArgumentList.Arguments[0].Expression is LiteralExpressionSyntax cLit
                && cLit.IsKind(SyntaxKind.StringLiteralExpression))
            {
                var compName = cLit.Token.ValueText;
                var comp = children.FirstOrDefault(c => c.Name == compName);
                if (comp != null) return new(AttributeContextKind.Child, comp);
                return new(AttributeContextKind.None, null);
            }

            // Parent.Attributes[".."]
            if (ma.Expression is IdentifierNameSyntax pid && pid.Identifier.ValueText == "Parent")
                return parent != null ? new(AttributeContextKind.Parent, parent) : new(AttributeContextKind.None, null);
        }

        return new(AttributeContextKind.None, null);
    }

    /// <summary>
    /// Finds every reference to a forbidden API — the documented script trust model,
    /// see <see cref="ForbiddenNamespacePrefixes"/>. Identifiers are resolved against
    /// the semantic model, so a forbidden type or member is caught however it is
    /// written: bare (<c>File</c>), fully qualified
    /// (<c>System.IO.File.WriteAllText</c>), or via an alias — while a user identifier
    /// that merely shares a name with a forbidden type (<c>var File = …</c>) does not
    /// false-positive. Used both for editor diagnostics and as the pre-execution
    /// trust-model gate (see <see cref="EnforceTrustModel"/>).
    /// </summary>
    private static IEnumerable<DiagnosticMarker> FindForbiddenApiUsages(SyntaxTree tree, SemanticModel model)
    {
        var root = tree.GetRoot();

        // Banned using directives.
        foreach (var u in root.DescendantNodes().OfType<UsingDirectiveSyntax>())
        {
            var name = u.Name?.ToString() ?? "";
            if (IsForbiddenName(name))
            {
                var span = u.GetLocation().GetLineSpan().Span;
                yield return new DiagnosticMarker(
                    Severity: 8,
                    StartLineNumber: span.Start.Line + 1,
                    StartColumn: span.Start.Character + 1,
                    EndLineNumber: span.End.Line + 1,
                    EndColumn: span.End.Character + 1,
                    Message: $"Forbidden namespace '{name}' is not allowed in scripts (script trust model).",
                    Code: "SCADA001");
            }
        }

        // Banned type / member references, resolved via the semantic model. Every
        // identifier is checked — including the right-hand side of a member access —
        // so a fully-qualified forbidden call (System.IO.File.WriteAllText) cannot
        // slip past by avoiding a `using` directive or a bare type name.
        foreach (var ident in root.DescendantNodes().OfType<IdentifierNameSyntax>())
        {
            var forbidden = ForbiddenNameFor(model.GetSymbolInfo(ident).Symbol);
            if (forbidden == null) continue;

            var span = ident.GetLocation().GetLineSpan().Span;
            yield return new DiagnosticMarker(
                Severity: 8,
                StartLineNumber: span.Start.Line + 1,
                StartColumn: span.Start.Character + 1,
                EndLineNumber: span.End.Line + 1,
                EndColumn: span.End.Character + 1,
                Message: $"'{ident.Identifier.ValueText}' resolves to forbidden API '{forbidden}', " +
                         "which is not allowed in scripts (script trust model).",
                Code: "SCADA002");
        }
    }

    /// <summary>
    /// The forbidden namespace/type a symbol implicates, or null if it is allowed.
    /// Checks the symbol's namespace and — for a type or member — the type's full
    /// name, so an entry like <c>System.Threading.Thread</c> bans that exact type
    /// while <c>System.Threading</c> (e.g. <c>CancellationToken</c>) stays allowed.
    /// </summary>
    private static string? ForbiddenNameFor(ISymbol? symbol)
    {
        if (symbol == null) return null;
        foreach (var name in QualifiedNamesOf(symbol))
            if (IsForbiddenName(name))
                return name;
        return null;
    }

    /// <summary>Fully-qualified names a symbol reference implicates for trust-model checking.</summary>
    private static IEnumerable<string> QualifiedNamesOf(ISymbol symbol)
    {
        switch (symbol)
        {
            case INamespaceSymbol { IsGlobalNamespace: false } ns:
                yield return ns.ToDisplayString();
                break;
            case ITypeSymbol type:
                if (type.ContainingNamespace is { IsGlobalNamespace: false } tn)
                    yield return tn.ToDisplayString();
                yield return FullTypeName(type);
                break;
            default:
                if (symbol.ContainingType is { } ct)
                {
                    if (ct.ContainingNamespace is { IsGlobalNamespace: false } cn)
                        yield return cn.ToDisplayString();
                    yield return FullTypeName(ct);
                }
                break;
        }
    }

    private static string FullTypeName(ITypeSymbol type) =>
        type.ContainingNamespace is { IsGlobalNamespace: false } ns
            ? ns.ToDisplayString() + "." + type.Name
            : type.Name;

    private static bool IsForbiddenName(string qualifiedName) =>
        ForbiddenNamespacePrefixes.Any(p =>
            qualifiedName == p || qualifiedName.StartsWith(p + ".", StringComparison.Ordinal));

    /// <summary>
    /// Pre-execution trust-model gate (CentralUI-001). Returns the forbidden-API
    /// markers (SCADA001/SCADA002) for a compiled script; an empty list means the
    /// script is clear to run. This is a static semantic gate, not a process
    /// sandbox — reflection-based indirection is still out of its reach; full
    /// isolation would require running scripts in a separate constrained process.
    /// </summary>
    private static IReadOnlyList<DiagnosticMarker> EnforceTrustModel(Compilation compilation)
    {
        var tree = compilation.SyntaxTrees.FirstOrDefault();
        if (tree == null) return Array.Empty<DiagnosticMarker>();
        var model = compilation.GetSemanticModel(tree);
        return FindForbiddenApiUsages(tree, model).ToList();
    }

    private static CompletionItem ToCompletionItem(ISymbol symbol)
    {
        var kind = symbol.Kind switch
        {
            SymbolKind.Method => "Method",
            SymbolKind.Property => "Property",
            SymbolKind.Field => "Field",
            SymbolKind.Event => "Event",
            SymbolKind.NamedType => "Class",
            SymbolKind.Local => "Variable",
            SymbolKind.Parameter => "Variable",
            SymbolKind.Namespace => "Module",
            _ => "Text"
        };
        return new CompletionItem(
            Label: symbol.Name,
            InsertText: symbol.Name,
            Detail: symbol.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat),
            Kind: kind);
    }

    private static DiagnosticMarker ToMarker(Diagnostic d)
    {
        var span = d.Location.GetLineSpan().Span;
        var severity = d.Severity switch
        {
            DiagnosticSeverity.Error => 8,
            DiagnosticSeverity.Warning => 4,
            DiagnosticSeverity.Info => 2,
            _ => 1
        };
        return new DiagnosticMarker(
            Severity: severity,
            StartLineNumber: span.Start.Line + 1,
            StartColumn: span.Start.Character + 1,
            EndLineNumber: span.End.Line + 1,
            EndColumn: span.End.Character + 1,
            Message: d.GetMessage(),
            Code: d.Id);
    }

    private static int PositionToOffset(string code, int line, int column)
    {
        var offset = 0;
        var currentLine = 1;
        var currentCol = 1;
        for (int i = 0; i < code.Length; i++)
        {
            if (currentLine == line && currentCol == column) return offset;
            if (code[i] == '\n') { currentLine++; currentCol = 1; }
            else { currentCol++; }
            offset = i + 1;
        }
        return code.Length;
    }
}
