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 (Phase
///     7 plan decision #7 — 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>
///         Identifier matching is by spelling: the extractor looks for
///         <c>ctx.GetTag(...)</c> / <c>ctx.SetVirtualTag(...)</c> literally. 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's no way to smuggle a
///         dependency past the extractor while still having a working script.
///     </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>
    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 = [];

        public IReadOnlySet<string> Reads => _reads;
        public IReadOnlySet<string> Writes => _writes;
        public IReadOnlyList<DependencyRejection> Rejections => _rejections;

        public override void VisitInvocationExpression(InvocationExpressionSyntax node)
        {
            // Only interested in member-access form: ctx.GetTag(...) / ctx.SetVirtualTag(...).
            // Anything else (free functions, chained calls, static calls) is ignored — but
            // still visit children in case a ctx.GetTag call is nested inside.
            if (node.Expression is MemberAccessExpressionSyntax member)
            {
                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;
            if (pathArg is not LiteralExpressionSyntax literal
                || !literal.Token.IsKind(SyntaxKind.StringLiteralToken))
            {
                _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);
