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

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

/// <summary>
///     Parses a script's source text + extracts every <c>ctx.GetTag("literal")</c> and
///     <c>ctx.SetVirtualTag("literal", ...)</c> call. Outputs the static dependency set
///     the virtual-tag engine uses to build its change-trigger subscription graph (AST
///     inference, operator doesn't maintain a separate list).
/// </summary>
/// <remarks>
///     <para>
///         The tag-path argument MUST be a literal string expression. Variables,
///         concatenation, interpolation, and method-returned strings are rejected because
///         the extractor can't statically know what tag they'll resolve to at evaluation
///         time — the dependency graph needs to know every possible input up front.
///         Rejections carry the exact source span so the Admin UI can point at the offending
///         token.
///     </para>
///     <para>
///         Matching is by spelling: the extractor looks for member-access invocations
///         whose receiver identifier is literally <c>ctx</c> and whose method name is
///         <c>GetTag</c> or <c>SetVirtualTag</c>. A deliberately misspelled method call
///         (<c>ctx.GetTagz</c>) is not picked up but will also fail to compile against
///         <see cref="ScriptContext"/>, so there is no way to smuggle a dependency past the
///         extractor while still having a working script. Calls with the same method name on
///         a different receiver (<c>other.GetTag("X")</c>) are explicitly ignored so that
///         scripts defining local helper types with matching names do not produce spurious
///         dependencies.
///     </para>
/// </remarks>
public static class DependencyExtractor
{
    /// <summary>
    ///     Parse <paramref name="scriptSource"/> + return the inferred read + write tag
    ///     paths, or a list of rejection messages if non-literal paths were used.
    /// </summary>
    /// <param name="scriptSource">The script source code to analyze.</param>
    /// <returns>The inferred read/write tag dependencies, plus any non-literal-path rejections.</returns>
    public static DependencyExtractionResult Extract(string scriptSource)
    {
        if (string.IsNullOrWhiteSpace(scriptSource))
            return new DependencyExtractionResult(
                Reads: new HashSet<string>(StringComparer.Ordinal),
                Writes: new HashSet<string>(StringComparer.Ordinal),
                Rejections: []);

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

        var walker = new Walker();
        walker.Visit(root);

        return new DependencyExtractionResult(
            Reads: walker.Reads,
            Writes: walker.Writes,
            Rejections: walker.Rejections);
    }

    private sealed class Walker : CSharpSyntaxWalker
    {
        private readonly HashSet<string> _reads = new(StringComparer.Ordinal);
        private readonly HashSet<string> _writes = new(StringComparer.Ordinal);
        private readonly List<DependencyRejection> _rejections = [];

        /// <summary>Gets the set of tags read by the script.</summary>
        public IReadOnlySet<string> Reads => _reads;
        /// <summary>Gets the set of tags written by the script.</summary>
        public IReadOnlySet<string> Writes => _writes;
        /// <summary>Gets the list of rejections for non-literal tag paths.</summary>
        public IReadOnlyList<DependencyRejection> Rejections => _rejections;

        /// <inheritdoc />
        public override void VisitInvocationExpression(InvocationExpressionSyntax node)
        {
            // Only interested in ctx.GetTag(...) / ctx.SetVirtualTag(...) — member-access
            // form where the receiver is the identifier "ctx" (the ScriptGlobals<T>.ctx
            // field). Calls with the same method name on a different receiver (e.g.
            // someHelper.GetTag("X")) are ignored — not picking them up avoids spurious
            // dependencies when scripts define local types with matching method names.
            if (node.Expression is MemberAccessExpressionSyntax member
                && member.Expression is IdentifierNameSyntax receiver
                && receiver.Identifier.ValueText == "ctx")
            {
                var methodName = member.Name.Identifier.ValueText;
                if (methodName is nameof(ScriptContext.GetTag) or nameof(ScriptContext.SetVirtualTag))
                {
                    HandleTagCall(node, methodName);
                }
            }
            base.VisitInvocationExpression(node);
        }

        private void HandleTagCall(InvocationExpressionSyntax node, string methodName)
        {
            var args = node.ArgumentList.Arguments;
            if (args.Count == 0)
            {
                _rejections.Add(new DependencyRejection(
                    Span: node.Span,
                    Message: $"Call to ctx.{methodName} has no arguments. " +
                             "The tag path must be the first argument."));
                return;
            }

            var pathArg = args[0].Expression;
            // Accept any string-literal expression, including raw-string forms which
            // tokenize as SingleLineRawStringLiteralToken / MultiLineRawStringLiteralToken
            // rather than StringLiteralToken. Checking the expression kind
            // (StringLiteralExpression) covers all token kinds Roslyn assigns to literal
            // strings, so a """raw""" path is harvested rather than mis-rejected as a
            // dynamic path.
            if (pathArg is not LiteralExpressionSyntax literal
                || !literal.IsKind(SyntaxKind.StringLiteralExpression))
            {
                _rejections.Add(new DependencyRejection(
                    Span: pathArg.Span,
                    Message: $"Tag path passed to ctx.{methodName} must be a string literal. " +
                             $"Dynamic paths (variables, concatenation, interpolation, method " +
                             $"calls) are rejected at publish so the dependency graph can be " +
                             $"built statically. Got: {pathArg.Kind()} ({pathArg})"));
                return;
            }

            var path = (string?)literal.Token.Value ?? string.Empty;
            if (string.IsNullOrWhiteSpace(path))
            {
                _rejections.Add(new DependencyRejection(
                    Span: literal.Span,
                    Message: $"Tag path passed to ctx.{methodName} is empty or whitespace."));
                return;
            }

            if (methodName == nameof(ScriptContext.GetTag))
                _reads.Add(path);
            else
                _writes.Add(path);
        }
    }
}

/// <summary>Output of <see cref="DependencyExtractor.Extract"/>.</summary>
public sealed record DependencyExtractionResult(
    IReadOnlySet<string> Reads,
    IReadOnlySet<string> Writes,
    IReadOnlyList<DependencyRejection> Rejections)
{
    /// <summary>True when no rejections were recorded — safe to publish.</summary>
    public bool IsValid => Rejections.Count == 0;
}

/// <summary>A single non-literal-path rejection with the exact source span for UI pointing.</summary>
public sealed record DependencyRejection(TextSpan Span, string Message);
