using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;

namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;

/// <summary>
/// Semantic validation rules for a FlattenedConfiguration:
/// - CallScript/CallShared targets must reference existing scripts
/// - Parameter count and types must match
/// - Return type compatibility
/// - Trigger operand types: RangeViolation requires numeric attribute
/// - On-trigger script must exist
/// - Instance scripts cannot call alarm on-trigger scripts
/// </summary>
public class SemanticValidator
{
    // Known numeric data types for RangeViolation trigger type validation
    private static readonly HashSet<string> NumericDataTypes = new(StringComparer.OrdinalIgnoreCase)
    {
        "Int32", "Float", "Double"
    };

    /// <summary>
    /// Runs all semantic validation rules.
    /// </summary>
    /// <param name="configuration">The flattened configuration to validate.</param>
    /// <param name="sharedScripts">Shared scripts available for CallShared references.</param>
    /// <param name="alarmCapableConnectionNames">Connection names that support alarm subscriptions; used to validate native alarm source bindings.</param>
    /// <returns>A <see cref="ValidationResult"/> containing all semantic errors and warnings found.</returns>
    public ValidationResult Validate(
        FlattenedConfiguration configuration,
        IReadOnlyList<ResolvedScript>? sharedScripts = null,
        IReadOnlySet<string>? alarmCapableConnectionNames = null)
    {
        var errors = new List<ValidationEntry>();
        var warnings = new List<ValidationEntry>();

        var scriptNames = new HashSet<string>(
            configuration.Scripts.Select(s => s.CanonicalName), StringComparer.Ordinal);

        // Composition-delegated CallScript: a machine script may invoke a composed
        // child's script via Children["X"].CallScript("Y") (often with a DYNAMIC child
        // name, e.g. Children[side + "MESReceiver"]). ExtractCalls captures only the
        // literal leaf "Y"; the actual flattened script is the composed canonical
        // "X.Y". Since the child segment is dynamic it cannot be statically resolved,
        // so we accept the call as existing when ANY composed script has that leaf
        // name. The positional arg-count/type checks already self-skip for these
        // (their canonical name "X.Y" is not the literal key "Y" in the param map).
        var composedLeafNames = new HashSet<string>(
            configuration.Scripts
                .Select(s => s.CanonicalName)
                .Where(n => n.Contains('.'))
                .Select(n => n[(n.LastIndexOf('.') + 1)..]),
            StringComparer.Ordinal);

        var sharedScriptNames = new HashSet<string>(
            (sharedScripts ?? []).Select(s => s.CanonicalName), StringComparer.Ordinal);

        var attributeMap = new Dictionary<string, ResolvedAttribute>(StringComparer.Ordinal);
        foreach (var a in configuration.Attributes)
        {
            // Skip duplicates — naming collisions are reported separately
            attributeMap.TryAdd(a.CanonicalName, a);
        }

        // List-attribute type semantics: element-type cardinality + default
        // value parseability. Trigger-operand rejection (rule 3) is handled below
        // by the existing NumericDataTypes guard (List is never numeric).
        ValidateListAttributes(configuration, errors);

        // Collect alarm on-trigger script names for cross-call violation checks
        var alarmOnTriggerScripts = new HashSet<string>(StringComparer.Ordinal);
        foreach (var alarm in configuration.Alarms)
        {
            if (!string.IsNullOrWhiteSpace(alarm.OnTriggerScriptCanonicalName))
                alarmOnTriggerScripts.Add(alarm.OnTriggerScriptCanonicalName);
        }

        // Build parameter maps for call target validation
        var scriptParamMap = BuildParameterMap(configuration.Scripts);
        var sharedParamMap = BuildParameterMap(sharedScripts ?? []);
        var scriptReturnMap = BuildReturnMap(configuration.Scripts);
        var sharedReturnMap = BuildReturnMap(sharedScripts ?? []);

        foreach (var script in configuration.Scripts)
        {
            var callTargets = ExtractCallTargets(script.Code);

            foreach (var call in callTargets)
            {
                if (call.IsShared)
                {
                    // CallShared targets must reference existing shared scripts
                    if (!sharedScriptNames.Contains(call.TargetName))
                    {
                        errors.Add(ValidationEntry.Error(ValidationCategory.CallTargetNotFound,
                            $"Script '{script.CanonicalName}' calls shared script '{call.TargetName}' which does not exist.",
                            script.CanonicalName));
                    }
                    else
                    {
                        ValidateCallParameters(script.CanonicalName, call, sharedParamMap, errors);
                        ValidateCallReturnType(script.CanonicalName, call, sharedReturnMap, errors);
                    }
                }
                else
                {
                    // CallScript targets must reference an existing instance script —
                    // either a same-scope sibling (canonical name) or a composition-
                    // delegated child script (leaf-name match; see composedLeafNames).
                    if (!scriptNames.Contains(call.TargetName)
                        && !composedLeafNames.Contains(call.TargetName))
                    {
                        errors.Add(ValidationEntry.Error(ValidationCategory.CallTargetNotFound,
                            $"Script '{script.CanonicalName}' calls script '{call.TargetName}' which does not exist.",
                            script.CanonicalName));
                    }
                    else
                    {
                        // If the target resolved ONLY via the composed
                        // leaf-name fallback (not a same-scope sibling), the child
                        // path is dynamic and cannot be statically verified — surface
                        // an advisory warning instead of accepting it silently.
                        if (!scriptNames.Contains(call.TargetName)
                            && composedLeafNames.Contains(call.TargetName))
                        {
                            warnings.Add(ValidationEntry.Warning(ValidationCategory.CallTargetNotFound,
                                $"Script '{script.CanonicalName}' call target '{call.TargetName}' matched by composed leaf name only — child path not verified.",
                                script.CanonicalName));
                        }

                        ValidateCallParameters(script.CanonicalName, call, scriptParamMap, errors);
                        ValidateCallReturnType(script.CanonicalName, call, scriptReturnMap, errors);

                        // Instance scripts cannot call alarm on-trigger scripts
                        if (alarmOnTriggerScripts.Contains(call.TargetName))
                        {
                            errors.Add(ValidationEntry.Error(ValidationCategory.CrossCallViolation,
                                $"Script '{script.CanonicalName}' calls alarm on-trigger script '{call.TargetName}' which is not allowed.",
                                script.CanonicalName));
                        }
                    }
                }
            }
        }

        // Validate Call-type scripts have parameter definitions
        foreach (var script in configuration.Scripts)
        {
            if (string.Equals(script.TriggerType, "Call", StringComparison.OrdinalIgnoreCase))
            {
                if (string.IsNullOrWhiteSpace(script.ParameterDefinitions))
                {
                    warnings.Add(ValidationEntry.Warning(ValidationCategory.MissingMetadata,
                        $"Call-type script '{script.CanonicalName}' has no parameter definitions.",
                        script.CanonicalName));
                }
            }
        }

        // Validate alarm trigger operand types
        foreach (var alarm in configuration.Alarms)
        {
            // RangeViolation requires numeric attribute
            if (alarm.TriggerType == "RangeViolation" && !string.IsNullOrWhiteSpace(alarm.TriggerConfiguration))
            {
                var attrName = ValidationService.ExtractAttributeNameFromTriggerConfig(alarm.TriggerConfiguration);
                if (attrName != null && attributeMap.TryGetValue(attrName, out var attr))
                {
                    if (!NumericDataTypes.Contains(attr.DataType))
                    {
                        errors.Add(ValidationEntry.Error(ValidationCategory.TriggerOperandType,
                            $"Alarm '{alarm.CanonicalName}' uses RangeViolation trigger on attribute '{attrName}' which has non-numeric type '{attr.DataType}'.",
                            alarm.CanonicalName));
                    }
                }
            }

            // HiLo requires numeric attribute + ordered setpoints
            if (alarm.TriggerType == "HiLo" && !string.IsNullOrWhiteSpace(alarm.TriggerConfiguration))
            {
                var attrName = ValidationService.ExtractAttributeNameFromTriggerConfig(alarm.TriggerConfiguration);
                if (attrName != null && attributeMap.TryGetValue(attrName, out var attr))
                {
                    if (!NumericDataTypes.Contains(attr.DataType))
                    {
                        errors.Add(ValidationEntry.Error(ValidationCategory.TriggerOperandType,
                            $"Alarm '{alarm.CanonicalName}' uses HiLo trigger on attribute '{attrName}' which has non-numeric type '{attr.DataType}'.",
                            alarm.CanonicalName));
                    }
                }

                var setpoints = ValidationService.ExtractHiLoSetpoints(alarm.TriggerConfiguration);

                // At least one setpoint must be configured — otherwise the alarm
                // can never fire.
                if (!setpoints.LoLo.HasValue && !setpoints.Lo.HasValue
                    && !setpoints.Hi.HasValue && !setpoints.HiHi.HasValue)
                {
                    warnings.Add(ValidationEntry.Warning(ValidationCategory.TriggerOperandType,
                        $"Alarm '{alarm.CanonicalName}' is HiLo but no setpoints (LoLo/Lo/Hi/HiHi) are configured — it will never fire.",
                        alarm.CanonicalName));
                }

                // Ordering: LoLo ≤ Lo, Hi ≤ HiHi, and the highest Lo-side band
                // must sit strictly below the lowest Hi-side band — otherwise the
                // bands overlap and the evaluator's behavior is ambiguous.
                if (setpoints.LoLo is { } loLo && setpoints.Lo is { } lo && loLo > lo)
                {
                    errors.Add(ValidationEntry.Error(ValidationCategory.TriggerOperandType,
                        $"Alarm '{alarm.CanonicalName}' HiLo setpoints out of order: LoLo ({loLo}) must be ≤ Lo ({lo}).",
                        alarm.CanonicalName));
                }
                if (setpoints.Hi is { } hi && setpoints.HiHi is { } hiHi && hi > hiHi)
                {
                    errors.Add(ValidationEntry.Error(ValidationCategory.TriggerOperandType,
                        $"Alarm '{alarm.CanonicalName}' HiLo setpoints out of order: Hi ({hi}) must be ≤ HiHi ({hiHi}).",
                        alarm.CanonicalName));
                }
                var highestLowSide = setpoints.Lo ?? setpoints.LoLo;
                var lowestHighSide = setpoints.Hi ?? setpoints.HiHi;
                if (highestLowSide is { } lowSide && lowestHighSide is { } highSide
                    && lowSide >= highSide)
                {
                    errors.Add(ValidationEntry.Error(ValidationCategory.TriggerOperandType,
                        $"Alarm '{alarm.CanonicalName}' HiLo bands overlap: low-side setpoint ({lowSide}) must be strictly less than high-side setpoint ({highSide}).",
                        alarm.CanonicalName));
                }

                // Deadbands must be non-negative — negative deadband would invert
                // the hysteresis (alarm could escape faster than it entered).
                foreach (var (name, value) in new (string, double?)[] {
                    ("LoLo deadband", setpoints.LoLoDeadband),
                    ("Lo deadband", setpoints.LoDeadband),
                    ("Hi deadband", setpoints.HiDeadband),
                    ("HiHi deadband", setpoints.HiHiDeadband)
                })
                {
                    if (value is { } d && d < 0)
                    {
                        errors.Add(ValidationEntry.Error(ValidationCategory.TriggerOperandType,
                            $"Alarm '{alarm.CanonicalName}' {name} ({d}) must be non-negative.",
                            alarm.CanonicalName));
                    }
                }
            }

            // On-trigger script must exist
            if (!string.IsNullOrWhiteSpace(alarm.OnTriggerScriptCanonicalName) &&
                !scriptNames.Contains(alarm.OnTriggerScriptCanonicalName))
            {
                errors.Add(ValidationEntry.Error(ValidationCategory.OnTriggerScriptNotFound,
                    $"Alarm '{alarm.CanonicalName}' references on-trigger script '{alarm.OnTriggerScriptCanonicalName}' which does not exist.",
                    alarm.CanonicalName));
            }
        }

        // Native alarm source bindings: connection + source reference must be
        // present, and (when the alarm-capable connection set is supplied) the
        // connection must resolve to an alarm-capable site data connection.
        foreach (var nativeSource in configuration.NativeAlarmSources)
        {
            if (string.IsNullOrWhiteSpace(nativeSource.SourceReference))
            {
                errors.Add(ValidationEntry.Error(ValidationCategory.NativeAlarmSourceInvalid,
                    $"Native alarm source '{nativeSource.CanonicalName}' has an empty source reference.",
                    nativeSource.CanonicalName));
            }

            if (string.IsNullOrWhiteSpace(nativeSource.ConnectionName))
            {
                errors.Add(ValidationEntry.Error(ValidationCategory.NativeAlarmSourceInvalid,
                    $"Native alarm source '{nativeSource.CanonicalName}' has no data connection.",
                    nativeSource.CanonicalName));
            }
            else if (alarmCapableConnectionNames is not null &&
                     !alarmCapableConnectionNames.Contains(nativeSource.ConnectionName))
            {
                errors.Add(ValidationEntry.Error(ValidationCategory.NativeAlarmSourceInvalid,
                    $"Native alarm source '{nativeSource.CanonicalName}' references connection '{nativeSource.ConnectionName}' which is not an alarm-capable data connection on this site.",
                    nativeSource.CanonicalName));
            }
        }

        return new ValidationResult { Errors = errors, Warnings = warnings };
    }

    /// <summary>
    /// Semantic validation of List-attribute type configuration. Two rules:
    /// <list type="number">
    /// <item><b>Element-type cardinality.</b> A <see cref="DataType.List"/> attribute
    ///   must carry a non-empty <see cref="ResolvedAttribute.ElementDataType"/> that is
    ///   a valid element scalar (see <see cref="AttributeValueCodec.IsValidElementType"/>);
    ///   a non-List attribute must NOT carry an element type.</item>
    /// <item><b>Default-value parseability.</b> A non-empty authored default
    ///   <see cref="ResolvedAttribute.Value"/> on a List attribute must
    ///   <see cref="AttributeValueCodec.Decode"/> without throwing.</item>
    /// </list>
    /// Attributes whose <see cref="ResolvedAttribute.DataType"/> doesn't parse to a
    /// known <see cref="DataType"/> are skipped here (their data type is not "List",
    /// so only the "no element type" half could apply, and an unparseable type is a
    /// separate concern not introduced by this feature).
    /// </summary>
    private static void ValidateListAttributes(
        FlattenedConfiguration configuration,
        List<ValidationEntry> errors)
    {
        foreach (var attr in configuration.Attributes)
        {
            var isList = string.Equals(attr.DataType, nameof(DataType.List), StringComparison.OrdinalIgnoreCase);
            var hasElementType = !string.IsNullOrWhiteSpace(attr.ElementDataType);

            // ── Rule 1: element-type cardinality ─────────────────────────────
            if (!isList)
            {
                if (hasElementType)
                {
                    errors.Add(ValidationEntry.Error(ValidationCategory.MissingMetadata,
                        $"Attribute '{attr.CanonicalName}' has data type '{attr.DataType}' but declares an element type '{attr.ElementDataType}'; element types are only valid on List attributes.",
                        attr.CanonicalName));
                }
                continue; // Non-List attributes have no list-specific value to check.
            }

            if (!hasElementType)
            {
                errors.Add(ValidationEntry.Error(ValidationCategory.MissingMetadata,
                    $"List attribute '{attr.CanonicalName}' must declare an element type (one of String, Int32, Float, Double, Boolean, DateTime).",
                    attr.CanonicalName));
                continue; // Without an element type we can't validate the default value.
            }

            if (!Enum.TryParse<DataType>(attr.ElementDataType, ignoreCase: true, out var elementType)
                || !AttributeValueCodec.IsValidElementType(elementType))
            {
                errors.Add(ValidationEntry.Error(ValidationCategory.MissingMetadata,
                    $"List attribute '{attr.CanonicalName}' has element type '{attr.ElementDataType}', which is not a valid element scalar (one of String, Int32, Float, Double, Boolean, DateTime).",
                    attr.CanonicalName));
                continue; // A bad element type makes the default-value check meaningless.
            }

            // ── Rule 2: default-value parseability ───────────────────────────
            if (!string.IsNullOrWhiteSpace(attr.Value))
            {
                try
                {
                    AttributeValueCodec.Decode(attr.Value, DataType.List, elementType);
                }
                catch (FormatException ex)
                {
                    errors.Add(ValidationEntry.Error(ValidationCategory.MissingMetadata,
                        $"List attribute '{attr.CanonicalName}' has a default value that is not a valid list of '{elementType}': {ex.Message}",
                        attr.CanonicalName));
                }
            }
        }
    }

    private static void ValidateCallParameters(
        string callerName,
        CallTarget call,
        Dictionary<string, List<string>> paramMap,
        List<ValidationEntry> errors)
    {
        if (!paramMap.TryGetValue(call.TargetName, out var expectedParams))
            return;

        if (call.ArgumentCount != expectedParams.Count)
        {
            errors.Add(ValidationEntry.Error(ValidationCategory.ParameterMismatch,
                $"Script '{callerName}' calls '{call.TargetName}' with {call.ArgumentCount} arguments but {expectedParams.Count} are expected.",
                callerName));
            // Count mismatch already reported — positional type matching below
            // would be misaligned, so don't compound the noise.
            return;
        }

        ValidateArgumentTypes(callerName, call, expectedParams, errors);
    }

    /// <summary>
    /// Argument-type validation. Compares each positionally-matched call
    /// argument expression against the target's declared parameter type and
    /// flags only CLEAR cross-category mismatches.
    ///
    /// Conservatism (false-positive avoidance) — a parameter is checked only
    /// when BOTH sides are confidently known:
    /// <list type="bullet">
    /// <item>Declared type must normalize to a known primitive (String, Integer,
    ///   Float, Boolean). <c>Object</c>/<c>List</c>/unknown declarations accept
    ///   anything — never flagged.</item>
    /// <item>Argument expression type must be inferable from a literal
    ///   (string/char, integer, decimal, <c>true</c>/<c>false</c>). Variables,
    ///   member access, method/await chains, <c>null</c>, casts, object/array
    ///   initializers, and anything else infer to Unknown and are never flagged.</item>
    /// <item>Integer⇄Float is treated as compatible (numeric widening) — never
    ///   flagged.</item>
    /// </list>
    /// </summary>
    private static void ValidateArgumentTypes(
        string callerName,
        CallTarget call,
        List<string> expectedParams,
        List<ValidationEntry> errors)
    {
        // Argument expressions are aligned 1:1 with parameters here (count was
        // verified equal by the caller). If the argument text couldn't be split
        // (e.g. it wasn't captured), skip silently.
        if (call.ArgumentExpressions.Count != expectedParams.Count)
            return;

        for (var i = 0; i < expectedParams.Count; i++)
        {
            var declared = NormalizeType(expectedParams[i]);
            if (declared is null)
                continue; // Object/List/unknown declaration accepts anything.

            var actual = InferLiteralType(call.ArgumentExpressions[i]);
            if (actual is null)
                continue; // Can't confidently infer the argument's type.

            if (!IsAssignable(actual.Value, declared.Value))
            {
                errors.Add(ValidationEntry.Error(ValidationCategory.ParameterMismatch,
                    $"Script '{callerName}' calls '{call.TargetName}' argument {i + 1} with type '{actual}' but parameter '{expectedParams[i]}' expects '{declared}'.",
                    callerName));
            }
        }
    }

    /// <summary>
    /// Return-type validation. When a call result is assigned directly
    /// into a typed local declaration (<c>int x = CallScript(...)</c>,
    /// <c>bool b = await CallShared(...)</c>), compares the LHS declared type
    /// against the target's declared return type and flags clear mismatches.
    ///
    /// Conservatism (false-positive avoidance) — flagged only when ALL hold:
    /// <list type="bullet">
    /// <item>The call result is captured by a typed local whose type is a known
    ///   primitive (so <c>var</c>, <c>object</c>, <c>dynamic</c>, and untyped
    ///   reuse are never flagged).</item>
    /// <item>The call is the WHOLE initializer (optionally preceded by
    ///   <c>await</c>). If the result feeds an expression / method chain
    ///   (e.g. <c>(int)(await CallScript(...))</c>, <c>CallScript(...).X</c>)
    ///   the assigned-type is not captured and nothing is flagged.</item>
    /// <item>The target declares a known-primitive return type. Missing/Object/
    ///   List/unknown returns are never flagged.</item>
    /// <item>Integer⇄Float is compatible (numeric widening) — never flagged.</item>
    /// </list>
    /// </summary>
    private static void ValidateCallReturnType(
        string callerName,
        CallTarget call,
        Dictionary<string, string?> returnMap,
        List<ValidationEntry> errors)
    {
        if (call.AssignedToType is null)
            return; // Result not captured by a typed local (var/untyped/unused).

        var expected = NormalizeType(call.AssignedToType);
        if (expected is null)
            return; // LHS isn't a known primitive — don't guess.

        if (!returnMap.TryGetValue(call.TargetName, out var returnDef))
            return;

        var actual = NormalizeType(ParseReturnDefinitionType(returnDef));
        if (actual is null)
            return; // Target's return type unknown/non-primitive.

        if (!IsAssignable(actual.Value, expected.Value))
        {
            errors.Add(ValidationEntry.Error(ValidationCategory.ReturnTypeMismatch,
                $"Script '{callerName}' assigns the '{actual}' return value of '{call.TargetName}' to a '{expected}' variable.",
                callerName));
        }
    }

    private static Dictionary<string, List<string>> BuildParameterMap(IReadOnlyList<ResolvedScript> scripts)
    {
        var result = new Dictionary<string, List<string>>(StringComparer.Ordinal);
        foreach (var script in scripts)
        {
            // Per-parameter declared TYPE in declared order (raw type strings).
            // One entry per parameter, so the existing count check is preserved
            // and positional matching has the types it needs.
            var parameters = ParseParameterTypes(script.ParameterDefinitions);
            result[script.CanonicalName] = parameters;
        }
        return result;
    }

    /// <summary>
    /// Parses a parameter definitions JSON string (JSON Schema or legacy flat
    /// array) and returns the declared parameter TYPE for each parameter, in
    /// declared order. Names are not needed for positional call validation; the
    /// returned count equals the parameter count (preserving the count check).
    /// </summary>
    /// <param name="parameterDefinitionsJson">JSON Schema or legacy flat-array string; null/empty returns an empty list.</param>
    /// <returns>The per-parameter raw type strings (e.g. "Int32", "string", "List").</returns>
    internal static List<string> ParseParameterTypes(string? parameterDefinitionsJson)
    {
        if (string.IsNullOrWhiteSpace(parameterDefinitionsJson))
            return [];

        try
        {
            using var doc = JsonDocument.Parse(parameterDefinitionsJson);
            // JSON Schema: { type:"object", properties:{ name:{ type:"integer" }, ... } }
            if (doc.RootElement.ValueKind == JsonValueKind.Object)
            {
                if (doc.RootElement.TryGetProperty("properties", out var props)
                    && props.ValueKind == JsonValueKind.Object)
                {
                    return props.EnumerateObject()
                        .Select(p => p.Value.ValueKind == JsonValueKind.Object
                                     && p.Value.TryGetProperty("type", out var t)
                                     && t.ValueKind == JsonValueKind.String
                            ? t.GetString() ?? "unknown"
                            : "unknown")
                        .ToList();
                }
            }
            // Legacy flat form: [{ name, type, required? }]
            else if (doc.RootElement.ValueKind == JsonValueKind.Array)
            {
                return doc.RootElement.EnumerateArray()
                    .Select(e => e.TryGetProperty("type", out var t) ? t.GetString() ?? "unknown" : "unknown")
                    .ToList();
            }
        }
        catch (JsonException)
        {
        }

        return [];
    }

    /// <summary>
    /// Extracts the declared return type from a ReturnDefinition JSON string
    /// (JSON Schema <c>{type:"..."}</c> or legacy <c>{type:"..."}</c>). Returns
    /// null when absent or unparseable.
    /// </summary>
    /// <param name="returnDefinitionJson">JSON return definition; null/empty returns null.</param>
    /// <returns>The raw return type string (e.g. "boolean", "Int32"), or null.</returns>
    internal static string? ParseReturnDefinitionType(string? returnDefinitionJson)
    {
        if (string.IsNullOrWhiteSpace(returnDefinitionJson))
            return null;

        try
        {
            using var doc = JsonDocument.Parse(returnDefinitionJson);
            if (doc.RootElement.ValueKind == JsonValueKind.Object
                && doc.RootElement.TryGetProperty("type", out var t)
                && t.ValueKind == JsonValueKind.String)
            {
                return t.GetString();
            }
        }
        catch (JsonException)
        {
        }

        return null;
    }

    private static Dictionary<string, string?> BuildReturnMap(IReadOnlyList<ResolvedScript> scripts)
    {
        var result = new Dictionary<string, string?>(StringComparer.Ordinal);
        foreach (var script in scripts)
        {
            result[script.CanonicalName] = script.ReturnDefinition;
        }
        return result;
    }

    /// <summary>
    /// Extracts call targets from script code by simple pattern matching.
    /// Looks for CallScript("name", ...) and CallShared("name", ...) patterns.
    /// </summary>
    /// <param name="code">The script source code to scan.</param>
    /// <returns>The list of call targets found (both <c>CallScript</c> and <c>CallShared</c> invocations).</returns>
    internal static List<CallTarget> ExtractCallTargets(string code)
    {
        var results = new List<CallTarget>();

        ExtractCallsOfType(code, "CallScript", false, results);
        ExtractCallsOfType(code, "CallShared", true, results);

        return results;
    }

    private static void ExtractCallsOfType(string code, string methodName, bool isShared, List<CallTarget> results)
    {
        var searchPattern = methodName + "(";
        int pos = 0;

        while (pos < code.Length)
        {
            var idx = code.IndexOf(searchPattern, pos, StringComparison.Ordinal);
            if (idx < 0) break;

            var argsStart = idx + searchPattern.Length;
            var target = ExtractStringArgument(code, argsStart);
            if (target != null)
            {
                // First argument is the script name; the rest are the call's
                // positional arguments.
                var args = SplitCallArguments(code, argsStart);
                var argExpressions = args.Count > 1
                    ? args.GetRange(1, args.Count - 1)
                    : new List<string>();

                results.Add(new CallTarget
                {
                    TargetName = target,
                    IsShared = isShared,
                    ArgumentCount = argExpressions.Count,
                    ArgumentExpressions = argExpressions,
                    // The declared type the result is assigned into, if the
                    // call is the whole initializer of a typed local declaration.
                    AssignedToType = ExtractAssignedToType(code, idx)
                });
            }

            pos = argsStart;
        }
    }

    /// <summary>
    /// Splits a call's argument list (starting just after the opening paren)
    /// into top-level argument expressions, trimmed. Tracks parenthesis, brace,
    /// and bracket nesting plus string/char literals so object initializers,
    /// nested calls, collection expressions, and commas inside literals don't
    /// produce spurious splits. Element 0 is the script-name argument.
    /// </summary>
    private static List<string> SplitCallArguments(string code, int startPos)
    {
        var args = new List<string>();
        var depthParen = 1; // we start inside the call's own '('
        var depthBraceBracket = 0;
        var pos = startPos;
        var argStart = startPos;

        while (pos < code.Length)
        {
            var c = code[pos];
            switch (c)
            {
                case '(':
                    depthParen++;
                    break;
                case ')':
                    depthParen--;
                    if (depthParen == 0)
                    {
                        AddArg(code, argStart, pos, args);
                        return args;
                    }
                    break;
                case '{':
                case '[':
                    depthBraceBracket++;
                    break;
                case '}':
                case ']':
                    if (depthBraceBracket > 0) depthBraceBracket--;
                    break;
                case ',' when depthParen == 1 && depthBraceBracket == 0:
                    AddArg(code, argStart, pos, args);
                    argStart = pos + 1;
                    break;
                case '"':
                case '\'':
                    // Skip the literal body so its delimiters/commas are ignored.
                    pos++;
                    while (pos < code.Length && code[pos] != c)
                    {
                        if (code[pos] == '\\') pos++; // skip escaped char
                        pos++;
                    }
                    break;
                case '/':
                    // Skip C# line and block comments so commas inside them are ignored.
                    // A `/` inside a string literal is already consumed above, so we only
                    // reach here for real `/` tokens in code.
                    if (pos + 1 < code.Length)
                    {
                        if (code[pos + 1] == '/')
                        {
                            // Line comment: skip to end-of-line.
                            pos += 2;
                            while (pos < code.Length && code[pos] != '\n') pos++;
                        }
                        else if (code[pos + 1] == '*')
                        {
                            // Block comment: skip to closing `*/`.
                            pos += 2;
                            while (pos + 1 < code.Length && !(code[pos] == '*' && code[pos + 1] == '/'))
                                pos++;
                            if (pos + 1 < code.Length) pos++; // step over the `/`
                        }
                    }
                    break;
            }
            pos++;
        }

        // Unterminated call (shouldn't happen for compilable code) — best effort.
        AddArg(code, argStart, code.Length, args);
        return args;

        static void AddArg(string code, int start, int end, List<string> acc)
        {
            var text = code[start..end].Trim();
            // Only the trailing empty slice after a lone name (e.g. "foo",) is
            // dropped; an empty arg list ("foo") still yields just the name.
            if (text.Length > 0 || acc.Count == 0)
                acc.Add(text);
        }
    }

    /// <summary>
    /// Inference — looks backwards from the call's start index for a typed
    /// local declaration whose initializer is exactly this call (optionally
    /// preceded by <c>await</c>). The call may be qualified by a simple receiver
    /// (<c>Instance.</c>, <c>Scripts.</c>, <c>Parent.</c>,
    /// <c>Children["x"].</c>) which is skipped. Returns the declared LHS type
    /// token, or null when the result isn't captured by a simple typed local
    /// (e.g. <c>var</c>, no assignment, reassignment to an existing variable, or
    /// the call is part of a larger expression such as a cast or longer
    /// member-access chain).
    /// </summary>
    private static string? ExtractAssignedToType(string code, int callIndex)
    {
        // Walk back over a simple dotted receiver immediately before the call —
        // e.g. the "Instance." / "Scripts." / "Children[\"x\"]." prefix on a
        // qualified call. Only identifier chars, '.', and bracketed indexers
        // (with string/identifier contents) are skipped; anything else (a ')',
        // an operator, another call's '(') means the call is embedded in a
        // larger expression and we must not infer.
        var receiverStart = SkipReceiverBackwards(code, callIndex);

        // Walk back over whitespace immediately before the receiver/call.
        var i = receiverStart - 1;
        while (i >= 0 && char.IsWhiteSpace(code[i])) i--;
        if (i < 0) return null;

        // The call must be the entire RHS: the char before it (after optional
        // 'await') must be '='. Anything else (')', '.', '(', operators) means
        // the result is consumed by a larger expression — don't infer.
        var beforeCall = code[..(i + 1)];

        // Strip a trailing 'await' so "= await CallScript(...)" is handled.
        var awaitTrimmed = beforeCall.TrimEnd();
        if (awaitTrimmed.EndsWith("await", StringComparison.Ordinal)
            && (awaitTrimmed.Length == 5 || !IsIdentifierChar(awaitTrimmed[^6])))
        {
            beforeCall = awaitTrimmed[..^5];
        }

        beforeCall = beforeCall.TrimEnd();
        if (!beforeCall.EndsWith('=')) return null;
        // Exclude '==', '<=', '>=', '!=' etc. — comparisons, not assignment.
        if (beforeCall.Length >= 2)
        {
            var prev = beforeCall[^2];
            if (prev is '=' or '!' or '<' or '>' or '+' or '-' or '*' or '/' or '%' or '&' or '|' or '^')
                return null;
        }

        // Now parse the "<type> <name>" declaration that precedes the '='.
        var decl = beforeCall[..^1].TrimEnd();

        // Identifier (the variable name).
        var end = decl.Length;
        var nameEnd = end;
        while (nameEnd > 0 && IsIdentifierChar(decl[nameEnd - 1])) nameEnd--;
        if (nameEnd == end) return null; // no identifier
        var nameStart = nameEnd;

        // Whitespace between type and name.
        var ws = nameStart;
        while (ws > 0 && char.IsWhiteSpace(decl[ws - 1])) ws--;
        if (ws == nameStart) return null; // need separating whitespace → "type name"

        // The type token (single identifier/keyword — no generics/arrays here;
        // those normalize to unknown anyway and stay unflagged).
        var typeEnd = ws;
        var typeStart = typeEnd;
        while (typeStart > 0 && IsIdentifierChar(decl[typeStart - 1])) typeStart--;
        if (typeStart == typeEnd) return null;

        // Guard against picking up a keyword that isn't a type in this position
        // (e.g. "return x = ..."). A real declaration's type token is preceded
        // by a statement boundary or open brace, not by another identifier.
        if (typeStart > 0)
        {
            var b = typeStart - 1;
            while (b >= 0 && char.IsWhiteSpace(decl[b])) b--;
            if (b >= 0 && IsIdentifierChar(decl[b]))
                return null; // preceded by another word → not a clean declaration
        }

        return decl[typeStart..typeEnd];
    }

    private static bool IsIdentifierChar(char c) => char.IsLetterOrDigit(c) || c == '_';

    /// <summary>
    /// Given the index of a <c>CallScript</c>/<c>CallShared</c> token, walks
    /// backwards over a leading receiver expression composed only of identifier
    /// chars, '.', and bracketed indexers (<c>["x"]</c>), and returns the index
    /// where that receiver begins. If there is no '.' immediately before the
    /// token (an unqualified call) the original index is returned unchanged.
    /// Stops at the first character that can't be part of such a simple
    /// receiver, so casts/parenthesised/chained-method receivers aren't
    /// mistaken for a clean assignment target.
    /// </summary>
    private static int SkipReceiverBackwards(string code, int callIndex)
    {
        var i = callIndex - 1;
        // Optional whitespace then must be a '.' for there to be a receiver.
        while (i >= 0 && char.IsWhiteSpace(code[i])) i--;
        if (i < 0 || code[i] != '.') return callIndex;

        var start = callIndex;
        while (i >= 0)
        {
            var c = code[i];
            if (c == '.' || IsIdentifierChar(c) || char.IsWhiteSpace(c))
            {
                start = i;
                i--;
                continue;
            }
            if (c == ']')
            {
                // Skip a single (non-nested) indexer "[ ... ]" with string or
                // identifier contents — e.g. Children["pump"].
                var j = i - 1;
                while (j >= 0 && code[j] != '[' && code[j] != '(' && code[j] != ')')
                    j--;
                if (j < 0 || code[j] != '[') return start;
                start = j;
                i = j - 1;
                continue;
            }
            break;
        }
        return start;
    }

    // ── Script-level type vocabulary ──────────────────────────────
    //
    // The template scripting "type system" exposed in ParameterDefinitions /
    // ReturnDefinition is a small set: String, Integer, Float, Boolean, plus
    // Object / List (and arbitrary unrecognised names). Only the four scalar
    // primitives below are matched; everything else maps to null ("unknown"),
    // which the validators treat as "accept anything / don't flag".

    private enum ScriptType { String, Integer, Float, Boolean }

    /// <summary>
    /// Maps a declared type token (JSON-Schema name, legacy name, or a C# type
    /// keyword used on a call-site LHS) onto a <see cref="ScriptType"/>, or null
    /// when the type isn't one of the confidently-checkable primitives.
    /// </summary>
    private static ScriptType? NormalizeType(string? raw)
    {
        if (string.IsNullOrWhiteSpace(raw)) return null;
        return raw.Trim().ToLowerInvariant() switch
        {
            "string" or "datetime" => ScriptType.String,
            "integer" or "int" or "int32" or "int64" or "long" or "short" or "byte" => ScriptType.Integer,
            "float" or "double" or "decimal" or "number" or "single" => ScriptType.Float,
            "boolean" or "bool" => ScriptType.Boolean,
            // Object, List, array, var, dynamic, and anything else → unknown.
            _ => null,
        };
    }

    /// <summary>
    /// Infers the <see cref="ScriptType"/> of a call-site argument expression,
    /// but ONLY for unambiguous literals. Returns null for variables, member
    /// access, method/await chains, <c>null</c>, casts, parenthesised/compound
    /// expressions, and object/array/collection initializers — those can't be
    /// statically typed here and must never be flagged.
    /// </summary>
    private static ScriptType? InferLiteralType(string expr)
    {
        expr = expr.Trim();
        if (expr.Length == 0) return null;

        // String / char literal — but only if the WHOLE expression is the
        // literal (so "a" + x or x + "b" stays unknown).
        if ((expr[0] == '"' || expr[0] == '\'') && IsWholeStringLiteral(expr))
            return ScriptType.String;
        if (expr.StartsWith('@') && expr.Length > 1 && expr[1] == '"' && IsWholeStringLiteral(expr[1..]))
            return ScriptType.String;
        if (expr.StartsWith('$'))
            return null; // interpolated string — string-ish, but be conservative.

        if (expr is "true" or "false")
            return ScriptType.Boolean;

        // Numeric literal (optionally signed). Float if it has a '.', 'e'/'E'
        // exponent, or a float/double/decimal suffix; otherwise Integer.
        if (IsNumericLiteral(expr, out var isFloat))
            return isFloat ? ScriptType.Float : ScriptType.Integer;

        return null; // Not a literal we can confidently classify.
    }

    private static bool IsWholeStringLiteral(string expr)
    {
        if (expr.Length < 2) return false;
        var quote = expr[0];
        if (quote != '"' && quote != '\'') return false;
        var i = 1;
        while (i < expr.Length)
        {
            if (expr[i] == '\\') { i += 2; continue; }
            if (expr[i] == quote) return i == expr.Length - 1; // closing quote must be last char
            i++;
        }
        return false;
    }

    private static bool IsNumericLiteral(string expr, out bool isFloat)
    {
        isFloat = false;
        var i = 0;
        if (expr.Length == 0) return false;
        if (expr[0] == '+' || expr[0] == '-') i++;

        // A genuine numeric literal must start with a digit or a `.` followed by a
        // digit.  Identifiers that start with `_` or a letter (e.g. `_2`, `count`)
        // are explicitly rejected here so they are inferred as Unknown, not Integer.
        if (i >= expr.Length) return false;
        var first = expr[i];
        if (first == '.')
        {
            if (i + 1 >= expr.Length || !char.IsDigit(expr[i + 1])) return false;
        }
        else if (!char.IsDigit(first))
        {
            return false; // starts with `_`, letter, or anything else → not a literal
        }

        var sawDigit = false;
        var sawDot = false;
        var sawExp = false;
        for (; i < expr.Length; i++)
        {
            var c = expr[i];
            if (char.IsDigit(c)) { sawDigit = true; continue; }
            if (c == '_' && sawDigit) continue; // digit separator — only valid between digits
            if (c == '.' && !sawDot && !sawExp) { sawDot = true; isFloat = true; continue; }
            if ((c == 'e' || c == 'E') && !sawExp && sawDigit)
            {
                sawExp = true; isFloat = true;
                if (i + 1 < expr.Length && (expr[i + 1] == '+' || expr[i + 1] == '-')) i++;
                continue;
            }
            // Numeric suffix terminates the literal.
            if (i == expr.Length - 1 || (i == expr.Length - 2))
            {
                var suffix = expr[i..].ToLowerInvariant();
                switch (suffix)
                {
                    case "f": case "d": case "m": isFloat = true; return sawDigit;
                    case "l": case "u": case "ul": case "lu": return sawDigit; // integer suffixes
                }
            }
            return false; // any other char → not a plain numeric literal
        }
        return sawDigit;
    }

    /// <summary>
    /// Whether an argument/return of <paramref name="actual"/> type is
    /// acceptable where <paramref name="expected"/> is declared. Exact match, or
    /// Integer⇄Float numeric widening. All other cross-category pairings
    /// (String↔number, String↔Boolean, Boolean↔number) are mismatches.
    /// </summary>
    private static bool IsAssignable(ScriptType actual, ScriptType expected)
    {
        if (actual == expected) return true;
        // Numeric widening / narrowing between Integer and Float is tolerated —
        // the scripting runtime coerces these and flagging them is noisy.
        return (actual == ScriptType.Integer && expected == ScriptType.Float)
            || (actual == ScriptType.Float && expected == ScriptType.Integer);
    }

    private static string? ExtractStringArgument(string code, int startPos)
    {
        // Skip whitespace
        var pos = startPos;
        while (pos < code.Length && char.IsWhiteSpace(code[pos])) pos++;

        if (pos >= code.Length) return null;

        // Expect a quote
        var quote = code[pos];
        if (quote != '"' && quote != '\'') return null;

        pos++;
        var nameStart = pos;
        while (pos < code.Length && code[pos] != quote) pos++;

        if (pos >= code.Length) return null;

        return code[nameStart..pos];
    }

    internal record CallTarget
    {
        /// <summary>Name of the script being called.</summary>
        public string TargetName { get; init; } = string.Empty;
        /// <summary>True when the call is to a shared script via <c>CallShared</c>.</summary>
        public bool IsShared { get; init; }
        /// <summary>Number of non-name arguments passed to the call.</summary>
        public int ArgumentCount { get; init; }
        /// <summary>The trimmed text of each non-name positional argument expression, in order.</summary>
        public IReadOnlyList<string> ArgumentExpressions { get; init; } = [];
        /// <summary>
        /// The declared type token the call result is assigned into, when the
        /// call is the whole initializer of a typed local declaration; otherwise
        /// null (var/untyped/unused/expression-embedded).
        /// </summary>
        public string? AssignedToType { get; init; }
    }
}
