using System.Security.Cryptography;
using System.Text;
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;

namespace ScadaLink.CentralUI.ScriptAnalysis;

/// <summary>
/// Compiles user scripts as Roslyn C# Scripting fragments against
/// <see cref="ScriptHost"/> globals 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), CallShared("...") names (from
///     <see cref="ISharedScriptCatalog"/>), and CallScript("...") names
///     (from the request's SiblingScripts).
///   - 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(ScriptHost).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;

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

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

        var cacheKey = "diag:" + 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: typeof(ScriptHost));
        }
        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 = 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(FindArgumentCountMismatches(tree, request.SiblingScripts));
            markers.AddRange(FindArgumentTypeMismatches(tree, request.SiblingScripts));
            markers.AddRange(FindUnknownAttributeKeys(tree, request));
            markers.AddRange(FindUnknownChildren(tree, request.Children));
        }

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

    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: typeof(ScriptHost));
        }
        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();
            }
        }

        // CallShared("...") / CallScript("...") / Children["X"].CallScript("...") / Parent.CallScript("...")
        if (owner is InvocationExpressionSyntax inv)
        {
            var calleeIdName = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText;
            var calleeMa = inv.Expression as MemberAccessExpressionSyntax;
            var calleeName = calleeIdName ?? calleeMa?.Name.Identifier.ValueText;

            if (calleeName == "CallShared")
            {
                var shapes = await _sharedScripts.GetShapesAsync();
                return shapes.Select(s => MakeCallCompletion(s, "shared script")).ToList();
            }

            if (calleeName == "CallScript")
            {
                // Children["X"].CallScript("..." or Parent.CallScript("...
                if (calleeMa != null)
                {
                    // Children["X"].CallScript
                    if (calleeMa.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 = (request.Children ?? Array.Empty<CompositionContext>())
                            .FirstOrDefault(c => c.Name == compName);
                        if (comp != null)
                            return comp.Scripts.Select(s => MakeCallCompletion(s, $"script on {compName}")).ToList();
                        return new List<CompletionItem>();
                    }
                    // Parent.CallScript
                    if (calleeMa.Expression is IdentifierNameSyntax pid
                        && pid.Identifier.ValueText == "Parent"
                        && request.Parent != null)
                    {
                        return request.Parent.Scripts
                            .Select(s => MakeCallCompletion(s, "parent script"))
                            .ToList();
                    }
                }

                // Plain CallScript("...") — siblings
                return (request.SiblingScripts ?? Array.Empty<ScriptShape>())
                    .Select(s => MakeCallCompletion(s, "sibling script"))
                    .ToList();
            }
        }

        return null;
    }

    /// <summary>
    /// Builds a Monaco snippet that fills the call after the name, e.g.
    /// <c>Greet", ${1:name}, ${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)
    {
        string insertText;
        int insertRules;
        if (shape.Parameters.Count == 0)
        {
            insertText = shape.Name + "\")";
            insertRules = 4;
        }
        else
        {
            var args = string.Join(", ", shape.Parameters.Select((p, i) => $"${{{i + 1}:{p.Name}}}"));
            insertText = $"{shape.Name}\", {args})";
            insertRules = 4;
        }
        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: insertRules);
    }

    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);
        }
    }

    public InlayHintsResponse InlayHints(InlayHintsRequest request)
    {
        if (string.IsNullOrEmpty(request.Code))
            return new InlayHintsResponse(Array.Empty<InlayHint>());

        var script = TryParse(request.Code);
        if (script == null) return new InlayHintsResponse(Array.Empty<InlayHint>());
        var (tree, _) = script.Value;

        IReadOnlyList<ScriptShape>? sharedShapes = null;
        IReadOnlyList<ScriptShape> SharedShapes() =>
            sharedShapes ??= _sharedScripts.GetShapesAsync().GetAwaiter().GetResult();

        var hints = new List<InlayHint>();
        foreach (var inv in tree.GetRoot().DescendantNodes().OfType<InvocationExpressionSyntax>())
        {
            var callee = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText;
            if (callee is not ("CallShared" or "CallScript")) continue;
            if (inv.ArgumentList.Arguments.Count < 1) continue;

            var nameArg = inv.ArgumentList.Arguments[0].Expression as LiteralExpressionSyntax;
            if (nameArg == null || !nameArg.IsKind(SyntaxKind.StringLiteralExpression)) continue;
            var scriptName = nameArg.Token.ValueText;
            if (string.IsNullOrEmpty(scriptName)) continue;

            ScriptShape? shape = callee == "CallShared"
                ? SharedShapes().FirstOrDefault(s => s.Name == scriptName)
                : request.SiblingScripts?.FirstOrDefault(s => s.Name == scriptName);
            if (shape == null) continue;

            for (var i = 1; i < inv.ArgumentList.Arguments.Count && i - 1 < shape.Parameters.Count; i++)
            {
                var arg = inv.ArgumentList.Arguments[i];
                var p = shape.Parameters[i - 1];
                var pos = arg.Span.Start;
                var lineSpan = tree.GetLineSpan(new TextSpan(pos, 0)).Span;
                hints.Add(new InlayHint(
                    Line: lineSpan.Start.Line + 1,
                    Column: lineSpan.Start.Character + 1,
                    Label: $"{p.Name}:"));
            }
        }

        return new InlayHintsResponse(hints);
    }

    public 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 calleeName = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText;
        var rawName = token.ValueText;
        if (string.IsNullOrEmpty(rawName)) return new HoverResponse(null);

        ScriptShape? shape = null;
        if (calleeName == "CallShared")
            shape = _sharedScripts.GetShapesAsync().GetAwaiter().GetResult()
                .FirstOrDefault(s => s.Name == rawName);
        else if (calleeName == "CallScript" && request.SiblingScripts != null)
            shape = request.SiblingScripts.FirstOrDefault(s => s.Name == rawName);

        if (shape == null) return new HoverResponse(null);
        return new HoverResponse(FormatHover(shape, calleeName!));
    }

    public 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 calleeName = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText;
        if (calleeName is not ("CallShared" or "CallScript")) 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 ?? "";

        ScriptShape? shape = null;
        if (calleeName == "CallShared")
            shape = _sharedScripts.GetShapesAsync().GetAwaiter().GetResult()
                .FirstOrDefault(s => s.Name == scriptName);
        else if (request.SiblingScripts != null)
            shape = request.SiblingScripts.FirstOrDefault(s => s.Name == scriptName);
        if (shape == null) return empty;

        var paramLabels = shape.Parameters.Select(p => $"{p.Name}: {p.Type}").ToList();
        var label = $"{calleeName}(\"{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(ScriptHost));
            var compilation = s.GetCompilation();
            var tree = compilation.SyntaxTrees.FirstOrDefault();
            return tree == null ? null : (tree, compilation);
        }
        catch
        {
            return null;
        }
    }

    private static string FormatHover(ScriptShape shape, string callee)
    {
        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";
        var kind = callee == "CallShared" ? "shared script" : "sibling script";
        return $"**{kind}** `{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 IEnumerable<DiagnosticMarker> FindArgumentCountMismatches(SyntaxTree tree, IReadOnlyList<ScriptShape>? siblings)
    {
        var root = tree.GetRoot();

        IReadOnlyList<ScriptShape>? sharedShapes = null;
        IReadOnlyList<ScriptShape> SharedShapes() =>
            sharedShapes ??= _sharedScripts.GetShapesAsync().GetAwaiter().GetResult();

        foreach (var inv in root.DescendantNodes().OfType<InvocationExpressionSyntax>())
        {
            var callee = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText;
            if (callee is not ("CallShared" or "CallScript")) continue;
            if (inv.ArgumentList.Arguments.Count < 1) continue;

            var nameArg = inv.ArgumentList.Arguments[0].Expression as LiteralExpressionSyntax;
            if (nameArg == null || !nameArg.IsKind(SyntaxKind.StringLiteralExpression)) continue;
            var scriptName = nameArg.Token.ValueText;
            if (string.IsNullOrEmpty(scriptName)) continue;

            ScriptShape? shape = callee == "CallShared"
                ? SharedShapes().FirstOrDefault(s => s.Name == scriptName)
                : siblings?.FirstOrDefault(s => s.Name == scriptName);
            if (shape == null) continue;

            var passedCount = inv.ArgumentList.Arguments.Count - 1; // exclude name
            var expectedRequired = shape.Parameters.Count(p => p.Required);
            var expectedTotal = shape.Parameters.Count;

            if (passedCount < expectedRequired || passedCount > expectedTotal)
            {
                var span = inv.GetLocation().GetLineSpan().Span;
                var expected = expectedRequired == expectedTotal
                    ? expectedTotal.ToString()
                    : $"{expectedRequired}–{expectedTotal}";
                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: $"{callee}('{scriptName}') expects {expected} argument(s) but got {passedCount}.",
                    Code: "SCADA004");
            }
        }
    }

    /// <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);
    }

    private IEnumerable<DiagnosticMarker> FindArgumentTypeMismatches(SyntaxTree tree, IReadOnlyList<ScriptShape>? siblings)
    {
        var root = tree.GetRoot();

        IReadOnlyList<ScriptShape>? sharedShapes = null;
        IReadOnlyList<ScriptShape> SharedShapes() =>
            sharedShapes ??= _sharedScripts.GetShapesAsync().GetAwaiter().GetResult();

        foreach (var inv in root.DescendantNodes().OfType<InvocationExpressionSyntax>())
        {
            var callee = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText;
            if (callee is not ("CallShared" or "CallScript")) continue;
            if (inv.ArgumentList.Arguments.Count < 1) continue;
            var nameArg = inv.ArgumentList.Arguments[0].Expression as LiteralExpressionSyntax;
            if (nameArg == null || !nameArg.IsKind(SyntaxKind.StringLiteralExpression)) continue;
            var scriptName = nameArg.Token.ValueText;
            if (string.IsNullOrEmpty(scriptName)) continue;

            ScriptShape? shape = callee == "CallShared"
                ? SharedShapes().FirstOrDefault(s => s.Name == scriptName)
                : siblings?.FirstOrDefault(s => s.Name == scriptName);
            if (shape == null) continue;

            for (var i = 1; i < inv.ArgumentList.Arguments.Count && i - 1 < shape.Parameters.Count; i++)
            {
                var arg = inv.ArgumentList.Arguments[i].Expression;
                var p = shape.Parameters[i - 1];
                var literalType = LiteralTypeOf(arg);
                if (literalType == null) continue; // Not a literal we can check.
                if (TypeAccepts(p.Type, literalType.Value)) continue;
                var span = arg.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: $"Argument {i} of {callee}('{scriptName}') expects {p.Type} but got {literalType}.",
                    Code: "SCADA005");
            }
        }
    }

    private enum LiteralKind { String, Integer, Float, Boolean, Null }

    private static LiteralKind? LiteralTypeOf(ExpressionSyntax expr)
    {
        if (expr is LiteralExpressionSyntax lit)
        {
            if (lit.IsKind(SyntaxKind.StringLiteralExpression)) return LiteralKind.String;
            if (lit.IsKind(SyntaxKind.TrueLiteralExpression) || lit.IsKind(SyntaxKind.FalseLiteralExpression))
                return LiteralKind.Boolean;
            if (lit.IsKind(SyntaxKind.NullLiteralExpression)) return LiteralKind.Null;
            if (lit.IsKind(SyntaxKind.NumericLiteralExpression))
            {
                var text = lit.Token.Text;
                return text.Contains('.') || text.EndsWith("f", StringComparison.OrdinalIgnoreCase)
                    || text.EndsWith("d", StringComparison.OrdinalIgnoreCase)
                        ? LiteralKind.Float
                        : LiteralKind.Integer;
            }
        }
        if (expr is InterpolatedStringExpressionSyntax) return LiteralKind.String;
        return null;
    }

    /// <summary>
    /// True when a literal of <paramref name="literal"/> is acceptable for a
    /// parameter declared as <paramref name="declared"/>. Object/List always
    /// accept (we don't introspect collection literals); Null is acceptable
    /// for any non-value type.
    /// </summary>
    private static bool TypeAccepts(string declared, LiteralKind literal)
    {
        var d = NormalizeDeclaredType(declared);
        if (literal == LiteralKind.Null) return d is "Object" or "List" or "String";
        return d switch
        {
            "Boolean" => literal == LiteralKind.Boolean,
            "Integer" => literal == LiteralKind.Integer,
            "Float" => literal is LiteralKind.Float or LiteralKind.Integer,
            "String" => literal == LiteralKind.String,
            "Object" or "List" => true,
            _ => true // unknown SCADA type — assume compatible
        };
    }

    /// <summary>
    /// Normalizes legacy / .NET type names from stored ParameterDefinitions
    /// JSON to the canonical Inbound API set. Mirrors the frontend
    /// ParameterListEditor's normalization so SCADA005 doesn't false-negative
    /// on data still in the legacy shape.
    /// </summary>
    private static string NormalizeDeclaredType(string declared) =>
        declared.ToLowerInvariant() switch
        {
            "boolean" or "bool" => "Boolean",
            "integer" or "int" or "int32" or "int64" or "int16" or "byte"
                or "sbyte" or "uint32" or "uint64" or "uint16" => "Integer",
            "float" or "double" or "single" or "decimal" => "Float",
            "string" or "datetime" => "String",
            "object" => "Object",
            "list" => "List",
            _ => declared
        };

    private static IEnumerable<DiagnosticMarker> FindForbiddenApiUsages(SyntaxTree tree, SemanticModel model)
    {
        var root = tree.GetRoot();

        // Banned using directives — pure namespace string match is fine here.
        foreach (var u in root.DescendantNodes().OfType<UsingDirectiveSyntax>())
        {
            var name = u.Name?.ToString() ?? "";
            if (ForbiddenNamespacePrefixes.Any(p => name == p || name.StartsWith(p + ".")))
            {
                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 usages — resolved via the semantic model so a user
        // identifier named "File" or "Thread" does NOT trigger the diagnostic
        // unless it actually resolves to a forbidden type.
        foreach (var ident in root.DescendantNodes().OfType<IdentifierNameSyntax>())
        {
            // Skip the identifier on the right side of a member access — only
            // the leftmost (the type or qualifier) is what we want to check.
            if (ident.Parent is MemberAccessExpressionSyntax m && m.Name == ident) continue;

            var symbol = model.GetSymbolInfo(ident).Symbol;
            if (symbol is not INamedTypeSymbol type) continue;

            var ns = type.ContainingNamespace?.ToDisplayString() ?? "";
            if (!ForbiddenNamespacePrefixes.Any(p => ns == p || ns.StartsWith(p + "."))) 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: $"Type '{type.Name}' from forbidden namespace '{ns}' is not allowed in scripts.",
                Code: "SCADA002");
        }
    }

    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;
    }
}
