using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;

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

public class SemanticValidatorTests
{
    private readonly SemanticValidator _sut = new();

    [Fact]
    public void Validate_NativeAlarmSource_UnknownConnection_ReturnsError()
    {
        var cfg = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            NativeAlarmSources = [new ResolvedNativeAlarmSource { CanonicalName = "P", ConnectionName = "Ghost", SourceReference = "x" }]
        };
        var result = _sut.Validate(cfg, alarmCapableConnectionNames: new HashSet<string> { "RealConn" });
        Assert.Contains(result.Errors, e => e.Category == ValidationCategory.NativeAlarmSourceInvalid);
    }

    [Fact]
    public void Validate_NativeAlarmSource_EmptySourceRef_ReturnsError()
    {
        var cfg = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            NativeAlarmSources = [new ResolvedNativeAlarmSource { CanonicalName = "P", ConnectionName = "RealConn", SourceReference = "" }]
        };
        var result = _sut.Validate(cfg, alarmCapableConnectionNames: new HashSet<string> { "RealConn" });
        Assert.Contains(result.Errors, e => e.Category == ValidationCategory.NativeAlarmSourceInvalid);
    }

    [Fact]
    public void Validate_NativeAlarmSource_ValidBinding_NoError()
    {
        var cfg = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            NativeAlarmSources = [new ResolvedNativeAlarmSource { CanonicalName = "P", ConnectionName = "RealConn", SourceReference = "ns=2;s=T1" }]
        };
        var result = _sut.Validate(cfg, alarmCapableConnectionNames: new HashSet<string> { "RealConn" });
        Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.NativeAlarmSourceInvalid);
    }

    [Fact]
    public void Validate_CallScriptTargetNotFound_ReturnsError()
    {
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Scripts =
            [
                new ResolvedScript
                {
                    CanonicalName = "Caller",
                    Code = "CallScript(\"NonExistent\");"
                }
            ]
        };

        var result = _sut.Validate(config);
        Assert.Contains(result.Errors, e =>
            e.Category == ValidationCategory.CallTargetNotFound &&
            e.Message.Contains("NonExistent"));
    }

    [Fact]
    public void Validate_CallScriptTargetExists_NoError()
    {
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Scripts =
            [
                new ResolvedScript { CanonicalName = "Target", Code = "var x = 1;" },
                new ResolvedScript { CanonicalName = "Caller", Code = "CallScript(\"Target\");" }
            ]
        };

        var result = _sut.Validate(config);
        Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.CallTargetNotFound);
    }

    [Fact]
    public void Validate_CallScriptCompositionDelegatedByLeafName_NoError()
    {
        // A machine script delegating to a composed child's script via
        // Children["X"].CallScript("MoveIn") captures only the leaf "MoveIn";
        // the flattened script is the composed canonical "LeftMESReceiver.MoveIn".
        // The existence check must accept the leaf-name match (the child segment
        // is typically dynamic and cannot be statically resolved).
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Scripts =
            [
                new ResolvedScript { CanonicalName = "LeftMESReceiver.MoveIn", Code = "var x = 1;" },
                new ResolvedScript { CanonicalName = "RightMESReceiver.MoveIn", Code = "var x = 1;" },
                new ResolvedScript { CanonicalName = "MesMoveIn", Code = "await Children[side + \"MESReceiver\"].CallScript(\"MoveIn\", Parameters);" }
            ]
        };

        var result = _sut.Validate(config);
        Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.CallTargetNotFound);
    }

    [Fact]
    public void Validate_CallScriptCompositionDelegatedByLeafName_EmitsAdvisoryWarning()
    {
        // #05-T24: a call target accepted ONLY by the composed leaf-name fallback
        // (dynamic child path, not statically verifiable) must surface an advisory
        // warning rather than being silently accepted.
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Scripts =
            [
                new ResolvedScript { CanonicalName = "LeftMESReceiver.MoveIn", Code = "var x = 1;" },
                new ResolvedScript { CanonicalName = "MesMoveIn", Code = "await Children[side + \"MESReceiver\"].CallScript(\"MoveIn\", Parameters);" }
            ]
        };

        var result = _sut.Validate(config);

        Assert.Contains(result.Warnings, w =>
            w.Category == ValidationCategory.CallTargetNotFound
            && w.Message.Contains("leaf name only", StringComparison.Ordinal));
    }

    [Fact]
    public void Validate_CallScriptSameScopeSibling_EmitsNoLeafWarning()
    {
        // A same-scope sibling resolves directly (not the leaf fallback), so no
        // advisory warning is emitted.
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Scripts =
            [
                new ResolvedScript { CanonicalName = "Target", Code = "var x = 1;" },
                new ResolvedScript { CanonicalName = "Caller", Code = "CallScript(\"Target\");" }
            ]
        };

        var result = _sut.Validate(config);

        Assert.DoesNotContain(result.Warnings, w =>
            w.Message.Contains("leaf name only", StringComparison.Ordinal));
    }

    [Fact]
    public void Validate_CallScriptUnknownLeafName_StillReturnsError()
    {
        // Relaxation is leaf-name scoped: a target that matches neither a root
        // canonical name nor any composed leaf name is still flagged.
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Scripts =
            [
                new ResolvedScript { CanonicalName = "LeftMESReceiver.MoveIn", Code = "var x = 1;" },
                new ResolvedScript { CanonicalName = "Caller", Code = "CallScript(\"Typo\");" }
            ]
        };

        var result = _sut.Validate(config);
        Assert.Contains(result.Errors, e =>
            e.Category == ValidationCategory.CallTargetNotFound && e.Message.Contains("Typo"));
    }

    [Fact]
    public void Validate_CallSharedTargetNotFound_ReturnsError()
    {
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Scripts =
            [
                new ResolvedScript
                {
                    CanonicalName = "Caller",
                    Code = "CallShared(\"MissingShared\");"
                }
            ]
        };

        var result = _sut.Validate(config, sharedScripts: []);
        Assert.Contains(result.Errors, e =>
            e.Category == ValidationCategory.CallTargetNotFound &&
            e.Message.Contains("MissingShared"));
    }

    [Fact]
    public void Validate_CallSharedTargetExists_NoError()
    {
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Scripts =
            [
                new ResolvedScript { CanonicalName = "Caller", Code = "CallShared(\"Utility\");" }
            ]
        };

        var shared = new List<ResolvedScript>
        {
            new() { CanonicalName = "Utility", Code = "// shared" }
        };

        var result = _sut.Validate(config, shared);
        Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.CallTargetNotFound);
    }

    [Fact]
    public void Validate_ParameterCountMismatch_ReturnsError()
    {
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Scripts =
            [
                new ResolvedScript
                {
                    CanonicalName = "Target",
                    Code = "var x = 1;",
                    ParameterDefinitions = "[{\"name\":\"a\",\"type\":\"Int32\"},{\"name\":\"b\",\"type\":\"String\"}]"
                },
                new ResolvedScript
                {
                    CanonicalName = "Caller",
                    Code = "CallScript(\"Target\", 42);" // 1 arg but 2 expected
                }
            ]
        };

        var result = _sut.Validate(config);
        Assert.Contains(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch);
    }

    // ── #21 Argument-type validation ────────────────────────────────────────

    [Fact]
    public void Validate_ArgumentTypeMismatch_StringForInteger_ReturnsError()
    {
        // Target expects (Integer a); caller passes a string literal.
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Scripts =
            [
                new ResolvedScript
                {
                    CanonicalName = "Target",
                    Code = "var x = 1;",
                    ParameterDefinitions = "[{\"name\":\"a\",\"type\":\"Int32\"}]"
                },
                new ResolvedScript
                {
                    CanonicalName = "Caller",
                    Code = "CallScript(\"Target\", \"hello\");"
                }
            ]
        };

        var result = _sut.Validate(config);
        Assert.Contains(result.Errors, e =>
            e.Category == ValidationCategory.ParameterMismatch &&
            e.Message.Contains("type", StringComparison.OrdinalIgnoreCase));
    }

    [Fact]
    public void Validate_ArgumentTypeMismatch_NumberForString_ReturnsError()
    {
        // Target expects (String a); caller passes an integer literal.
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Scripts =
            [
                new ResolvedScript
                {
                    CanonicalName = "Target",
                    Code = "var x = 1;",
                    ParameterDefinitions = "[{\"name\":\"a\",\"type\":\"String\"}]"
                },
                new ResolvedScript
                {
                    CanonicalName = "Caller",
                    Code = "CallScript(\"Target\", 42);"
                }
            ]
        };

        var result = _sut.Validate(config);
        Assert.Contains(result.Errors, e =>
            e.Category == ValidationCategory.ParameterMismatch &&
            e.Message.Contains("type", StringComparison.OrdinalIgnoreCase));
    }

    [Fact]
    public void Validate_ArgumentTypeMismatch_BooleanForInteger_ReturnsError()
    {
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Scripts =
            [
                new ResolvedScript
                {
                    CanonicalName = "Target",
                    Code = "var x = 1;",
                    ParameterDefinitions = "[{\"name\":\"a\",\"type\":\"Integer\"}]"
                },
                new ResolvedScript
                {
                    CanonicalName = "Caller",
                    Code = "CallScript(\"Target\", true);"
                }
            ]
        };

        var result = _sut.Validate(config);
        Assert.Contains(result.Errors, e =>
            e.Category == ValidationCategory.ParameterMismatch &&
            e.Message.Contains("type", StringComparison.OrdinalIgnoreCase));
    }

    [Fact]
    public void Validate_ArgumentTypeMatch_CorrectLiterals_NoError()
    {
        // (Integer a, String b, Boolean c) called with matching literals.
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Scripts =
            [
                new ResolvedScript
                {
                    CanonicalName = "Target",
                    Code = "var x = 1;",
                    ParameterDefinitions =
                        "[{\"name\":\"a\",\"type\":\"Integer\"},{\"name\":\"b\",\"type\":\"String\"},{\"name\":\"c\",\"type\":\"Boolean\"}]"
                },
                new ResolvedScript
                {
                    CanonicalName = "Caller",
                    Code = "CallScript(\"Target\", 42, \"hi\", true);"
                }
            ]
        };

        var result = _sut.Validate(config);
        Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch);
    }

    [Fact]
    public void Validate_ArgumentType_IntegerLiteralForFloat_NoError()
    {
        // Numeric widening: an integer literal is acceptable where a Float is declared.
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Scripts =
            [
                new ResolvedScript
                {
                    CanonicalName = "Target",
                    Code = "var x = 1;",
                    ParameterDefinitions = "[{\"name\":\"a\",\"type\":\"Float\"}]"
                },
                new ResolvedScript
                {
                    CanonicalName = "Caller",
                    Code = "CallScript(\"Target\", 5);"
                }
            ]
        };

        var result = _sut.Validate(config);
        Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch);
    }

    [Fact]
    public void Validate_ArgumentType_UnknownExpression_NoFalsePositive()
    {
        // The argument is a variable/expression whose type can't be statically
        // inferred — must NOT be flagged even though it could be anything.
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Scripts =
            [
                new ResolvedScript
                {
                    CanonicalName = "Target",
                    Code = "var x = 1;",
                    ParameterDefinitions = "[{\"name\":\"a\",\"type\":\"Integer\"}]"
                },
                new ResolvedScript
                {
                    CanonicalName = "Caller",
                    Code = "var v = Attributes[\"Temp\"].Value; CallScript(\"Target\", v);"
                }
            ]
        };

        var result = _sut.Validate(config);
        Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch);
    }

    [Fact]
    public void Validate_ArgumentType_ObjectInitializerArgument_NoFalsePositive()
    {
        // Real-world call shape: a single anonymous-object argument. Object
        // initializers can't be mapped to positional primitive params — skip.
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Scripts =
            [
                new ResolvedScript
                {
                    CanonicalName = "Target",
                    Code = "var x = 1;",
                    ParameterDefinitions = "[{\"name\":\"a\",\"type\":\"Integer\"}]"
                },
                new ResolvedScript
                {
                    CanonicalName = "Caller",
                    Code = "CallScript(\"Target\", new { a = 5 });"
                }
            ]
        };

        var result = _sut.Validate(config);
        Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch);
    }

    [Fact]
    public void Validate_ArgumentType_UntypedParameter_NoFalsePositive()
    {
        // Target declares an Object parameter — anything is assignable, no flag.
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Scripts =
            [
                new ResolvedScript
                {
                    CanonicalName = "Target",
                    Code = "var x = 1;",
                    ParameterDefinitions = "[{\"name\":\"a\",\"type\":\"Object\"}]"
                },
                new ResolvedScript
                {
                    CanonicalName = "Caller",
                    Code = "CallScript(\"Target\", \"anything\");"
                }
            ]
        };

        var result = _sut.Validate(config);
        Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch);
    }

    [Fact]
    public void Validate_ArgumentType_CompoundExpressionStartingWithLiteral_NoFalsePositive()
    {
        // `42 + offset` starts with an int literal but is a compound expression
        // of unknown type — must NOT be classified or flagged.
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Scripts =
            [
                new ResolvedScript
                {
                    CanonicalName = "Target",
                    Code = "var x = 1;",
                    ParameterDefinitions = "[{\"name\":\"a\",\"type\":\"String\"}]"
                },
                new ResolvedScript
                {
                    CanonicalName = "Caller",
                    Code = "CallScript(\"Target\", 42 + offset);"
                }
            ]
        };

        var result = _sut.Validate(config);
        Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch);
    }

    [Fact]
    public void Validate_ArgumentType_ConcatenatedStringExpression_NoFalsePositive()
    {
        // `"a" + x` starts with a string literal but is a concatenation of
        // unknown overall type — be conservative, don't flag against Integer.
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Scripts =
            [
                new ResolvedScript
                {
                    CanonicalName = "Target",
                    Code = "var x = 1;",
                    ParameterDefinitions = "[{\"name\":\"a\",\"type\":\"Integer\"}]"
                },
                new ResolvedScript
                {
                    CanonicalName = "Caller",
                    Code = "CallScript(\"Target\", \"a\" + x);"
                }
            ]
        };

        var result = _sut.Validate(config);
        Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch);
    }

    // ── #20 Return-type validation ──────────────────────────────────────────

    [Fact]
    public void Validate_ReturnTypeMismatch_BooleanResultIntoInt_ReturnsError()
    {
        // Target returns Boolean; caller assigns it into a typed `int` local.
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Scripts =
            [
                new ResolvedScript
                {
                    CanonicalName = "Target",
                    Code = "return true;",
                    ReturnDefinition = "{\"type\":\"boolean\"}"
                },
                new ResolvedScript
                {
                    CanonicalName = "Caller",
                    Code = "int x = CallScript(\"Target\");"
                }
            ]
        };

        var result = _sut.Validate(config);
        Assert.Contains(result.Errors, e => e.Category == ValidationCategory.ReturnTypeMismatch);
    }

    [Fact]
    public void Validate_ReturnTypeMismatch_StringResultIntoBool_ReturnsError()
    {
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Scripts =
            [
                new ResolvedScript
                {
                    CanonicalName = "Target",
                    Code = "return \"x\";",
                    ReturnDefinition = "{\"type\":\"string\"}"
                },
                new ResolvedScript
                {
                    CanonicalName = "Caller",
                    Code = "bool b = await CallScript(\"Target\");"
                }
            ]
        };

        var result = _sut.Validate(config);
        Assert.Contains(result.Errors, e => e.Category == ValidationCategory.ReturnTypeMismatch);
    }

    [Fact]
    public void Validate_ReturnTypeMatch_CompatibleAssignment_NoError()
    {
        // Target returns Integer; caller assigns into an `int` local — compatible.
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Scripts =
            [
                new ResolvedScript
                {
                    CanonicalName = "Target",
                    Code = "return 1;",
                    ReturnDefinition = "{\"type\":\"integer\"}"
                },
                new ResolvedScript
                {
                    CanonicalName = "Caller",
                    Code = "int x = CallScript(\"Target\");"
                }
            ]
        };

        var result = _sut.Validate(config);
        Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ReturnTypeMismatch);
    }

    [Fact]
    public void Validate_ReturnType_VarAssignment_NoFalsePositive()
    {
        // `var` LHS — caller's expected type can't be inferred, so no flag.
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Scripts =
            [
                new ResolvedScript
                {
                    CanonicalName = "Target",
                    Code = "return \"x\";",
                    ReturnDefinition = "{\"type\":\"string\"}"
                },
                new ResolvedScript
                {
                    CanonicalName = "Caller",
                    Code = "var x = CallScript(\"Target\");"
                }
            ]
        };

        var result = _sut.Validate(config);
        Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ReturnTypeMismatch);
    }

    [Fact]
    public void Validate_ReturnType_UnusedResult_NoFalsePositive()
    {
        // Result isn't assigned anywhere — nothing to check.
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Scripts =
            [
                new ResolvedScript
                {
                    CanonicalName = "Target",
                    Code = "return \"x\";",
                    ReturnDefinition = "{\"type\":\"string\"}"
                },
                new ResolvedScript
                {
                    CanonicalName = "Caller",
                    Code = "CallScript(\"Target\");"
                }
            ]
        };

        var result = _sut.Validate(config);
        Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ReturnTypeMismatch);
    }

    [Fact]
    public void Validate_ReturnType_UndeclaredReturn_NoFalsePositive()
    {
        // Target has no ReturnDefinition — can't compare, so no flag.
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Scripts =
            [
                new ResolvedScript { CanonicalName = "Target", Code = "return 1;" },
                new ResolvedScript
                {
                    CanonicalName = "Caller",
                    Code = "string s = CallScript(\"Target\");"
                }
            ]
        };

        var result = _sut.Validate(config);
        Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ReturnTypeMismatch);
    }

    [Fact]
    public void Validate_ReturnTypeMismatch_QualifiedInstanceCall_ReturnsError()
    {
        // Real-code form: `Instance.CallScript(...)`. The receiver prefix must
        // be skipped so #20 still sees the typed-local assignment.
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Scripts =
            [
                new ResolvedScript
                {
                    CanonicalName = "Target",
                    Code = "return \"x\";",
                    ReturnDefinition = "{\"type\":\"string\"}"
                },
                new ResolvedScript
                {
                    CanonicalName = "Caller",
                    Code = "bool b = await Instance.CallScript(\"Target\");"
                }
            ]
        };

        var result = _sut.Validate(config);
        Assert.Contains(result.Errors, e => e.Category == ValidationCategory.ReturnTypeMismatch);
    }

    [Fact]
    public void Validate_ReturnTypeMismatch_QualifiedSharedCall_ReturnsError()
    {
        // Real-code form: `Scripts.CallShared(...)`.
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Scripts =
            [
                new ResolvedScript
                {
                    CanonicalName = "Caller",
                    Code = "int n = Scripts.CallShared(\"Util\");"
                }
            ]
        };

        var shared = new List<ResolvedScript>
        {
            new()
            {
                CanonicalName = "Util",
                Code = "return true;",
                ReturnDefinition = "{\"type\":\"boolean\"}"
            }
        };

        var result = _sut.Validate(config, shared);
        Assert.Contains(result.Errors, e => e.Category == ValidationCategory.ReturnTypeMismatch);
    }

    [Fact]
    public void Validate_ReturnType_CastExpression_NoFalsePositive()
    {
        // The result feeds a cast expression — not a clean typed-local
        // assignment, so the assigned type can't be inferred. No flag.
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Scripts =
            [
                new ResolvedScript
                {
                    CanonicalName = "Target",
                    Code = "return \"x\";",
                    ReturnDefinition = "{\"type\":\"string\"}"
                },
                new ResolvedScript
                {
                    CanonicalName = "Caller",
                    Code = "int x = (int)CallScript(\"Target\");"
                }
            ]
        };

        var result = _sut.Validate(config);
        Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ReturnTypeMismatch);
    }

    [Fact]
    public void Validate_RangeViolationOnNonNumeric_ReturnsError()
    {
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Attributes =
            [
                new ResolvedAttribute { CanonicalName = "Status", DataType = "String" }
            ],
            Alarms =
            [
                new ResolvedAlarm
                {
                    CanonicalName = "BadAlarm",
                    TriggerType = "RangeViolation",
                    TriggerConfiguration = "{\"attributeName\":\"Status\"}"
                }
            ]
        };

        var result = _sut.Validate(config);
        Assert.Contains(result.Errors, e => e.Category == ValidationCategory.TriggerOperandType);
    }

    [Fact]
    public void Validate_RangeViolationOnNumeric_NoError()
    {
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Attributes =
            [
                new ResolvedAttribute { CanonicalName = "Temp", DataType = "Double" }
            ],
            Alarms =
            [
                new ResolvedAlarm
                {
                    CanonicalName = "HighTemp",
                    TriggerType = "RangeViolation",
                    TriggerConfiguration = "{\"attributeName\":\"Temp\"}"
                }
            ]
        };

        var result = _sut.Validate(config);
        Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.TriggerOperandType);
    }

    [Fact]
    public void Validate_OnTriggerScriptNotFound_ReturnsError()
    {
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Scripts = [new ResolvedScript { CanonicalName = "OtherScript", Code = "var x = 1;" }],
            Alarms =
            [
                new ResolvedAlarm
                {
                    CanonicalName = "Alarm1",
                    TriggerType = "ValueMatch",
                    OnTriggerScriptCanonicalName = "MissingScript"
                }
            ]
        };

        var result = _sut.Validate(config);
        Assert.Contains(result.Errors, e => e.Category == ValidationCategory.OnTriggerScriptNotFound);
    }

    [Fact]
    public void Validate_InstanceScriptCallsAlarmOnTrigger_ReturnsError()
    {
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Scripts =
            [
                new ResolvedScript { CanonicalName = "AlarmHandler", Code = "// alarm handler" },
                new ResolvedScript
                {
                    CanonicalName = "RegularScript",
                    Code = "CallScript(\"AlarmHandler\");"
                }
            ],
            Alarms =
            [
                new ResolvedAlarm
                {
                    CanonicalName = "Alarm1",
                    TriggerType = "ValueMatch",
                    OnTriggerScriptCanonicalName = "AlarmHandler"
                }
            ]
        };

        var result = _sut.Validate(config);
        Assert.Contains(result.Errors, e => e.Category == ValidationCategory.CrossCallViolation);
    }

    [Fact]
    public void ExtractCallTargets_MultipleCallTypes()
    {
        var code = @"
            var x = CallScript(""Script1"", arg1, arg2);
            CallShared(""Shared1"");
            CallScript(""Script2"");
        ";

        var targets = SemanticValidator.ExtractCallTargets(code);

        Assert.Equal(3, targets.Count);
        Assert.Contains(targets, t => t.TargetName == "Script1" && !t.IsShared && t.ArgumentCount == 2);
        Assert.Contains(targets, t => t.TargetName == "Shared1" && t.IsShared && t.ArgumentCount == 0);
        Assert.Contains(targets, t => t.TargetName == "Script2" && !t.IsShared && t.ArgumentCount == 0);
    }

    // ── HiLo validation ─────────────────────────────────────────────────────

    private static FlattenedConfiguration HiLoConfig(string attrName, string dataType, string triggerJson) =>
        new()
        {
            InstanceUniqueName = "Instance1",
            Attributes = [new ResolvedAttribute { CanonicalName = attrName, DataType = dataType }],
            Alarms =
            [
                new ResolvedAlarm
                {
                    CanonicalName = "Hi/Lo Alarm",
                    TriggerType = "HiLo",
                    TriggerConfiguration = triggerJson
                }
            ]
        };

    [Fact]
    public void Validate_HiLoOnNonNumericAttribute_ReturnsError()
    {
        var config = HiLoConfig("Status", "String",
            "{\"attributeName\":\"Status\",\"hi\":80}");

        var result = _sut.Validate(config);

        Assert.Contains(result.Errors,
            e => e.Category == ValidationCategory.TriggerOperandType
              && e.Message.Contains("HiLo")
              && e.Message.Contains("non-numeric"));
    }

    [Fact]
    public void Validate_HiLoOnNumericAttribute_NoOperandTypeError()
    {
        var config = HiLoConfig("Temp", "Double",
            "{\"attributeName\":\"Temp\",\"hi\":80,\"hiHi\":100}");

        var result = _sut.Validate(config);

        Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.TriggerOperandType);
    }

    [Fact]
    public void Validate_HiLoNoSetpoints_ReturnsWarning()
    {
        // No setpoints means the alarm can never fire — design-time warning.
        var config = HiLoConfig("Temp", "Double",
            "{\"attributeName\":\"Temp\"}");

        var result = _sut.Validate(config);

        Assert.Contains(result.Warnings,
            w => w.Category == ValidationCategory.TriggerOperandType
              && w.Message.Contains("no setpoints"));
    }

    [Fact]
    public void Validate_HiLoLoLoGreaterThanLo_ReturnsError()
    {
        var config = HiLoConfig("Temp", "Double",
            "{\"attributeName\":\"Temp\",\"loLo\":20,\"lo\":10}");

        var result = _sut.Validate(config);

        Assert.Contains(result.Errors,
            e => e.Category == ValidationCategory.TriggerOperandType
              && e.Message.Contains("LoLo")
              && e.Message.Contains("Lo"));
    }

    [Fact]
    public void Validate_HiLoHiGreaterThanHiHi_ReturnsError()
    {
        var config = HiLoConfig("Temp", "Double",
            "{\"attributeName\":\"Temp\",\"hi\":120,\"hiHi\":100}");

        var result = _sut.Validate(config);

        Assert.Contains(result.Errors,
            e => e.Category == ValidationCategory.TriggerOperandType
              && e.Message.Contains("Hi")
              && e.Message.Contains("HiHi"));
    }

    [Fact]
    public void Validate_HiLoLowSideOverlapsHighSide_ReturnsError()
    {
        // Lo (50) >= Hi (40) — bands overlap.
        var config = HiLoConfig("Temp", "Double",
            "{\"attributeName\":\"Temp\",\"lo\":50,\"hi\":40}");

        var result = _sut.Validate(config);

        Assert.Contains(result.Errors,
            e => e.Category == ValidationCategory.TriggerOperandType
              && e.Message.Contains("overlap"));
    }

    [Fact]
    public void Validate_HiLoOnlyHighSideConfigured_NoOrderingError()
    {
        // Only Hi/HiHi configured — no low-side comparison needed.
        var config = HiLoConfig("Temp", "Double",
            "{\"attributeName\":\"Temp\",\"hi\":80,\"hiHi\":100}");

        var result = _sut.Validate(config);

        Assert.DoesNotContain(result.Errors,
            e => e.Category == ValidationCategory.TriggerOperandType);
    }

    [Fact]
    public void Validate_HiLoNegativeDeadband_ReturnsError()
    {
        var config = HiLoConfig("Temp", "Double",
            "{\"attributeName\":\"Temp\",\"hi\":80,\"hiHi\":100,\"hiDeadband\":-1}");

        var result = _sut.Validate(config);

        Assert.Contains(result.Errors,
            e => e.Category == ValidationCategory.TriggerOperandType
              && e.Message.Contains("Hi deadband")
              && e.Message.Contains("non-negative"));
    }

    [Fact]
    public void Validate_HiLoZeroDeadband_NoError()
    {
        // Zero deadband is the default (no hysteresis) and must be accepted.
        var config = HiLoConfig("Temp", "Double",
            "{\"attributeName\":\"Temp\",\"hi\":80,\"hiHi\":100,\"hiDeadband\":0,\"hiHiDeadband\":0}");

        var result = _sut.Validate(config);

        Assert.DoesNotContain(result.Errors,
            e => e.Category == ValidationCategory.TriggerOperandType);
    }

    [Fact]
    public void Validate_HiLoValidOrdering_NoErrors()
    {
        // LoLo (-10) < Lo (0) < Hi (90) < HiHi (100) — fully valid.
        var config = HiLoConfig("Temp", "Double",
            "{\"attributeName\":\"Temp\",\"loLo\":-10,\"lo\":0,\"hi\":90,\"hiHi\":100}");

        var result = _sut.Validate(config);

        Assert.DoesNotContain(result.Errors,
            e => e.Category == ValidationCategory.TriggerOperandType);
        Assert.DoesNotContain(result.Warnings,
            w => w.Category == ValidationCategory.TriggerOperandType);
    }

    // ── M2.7 review nits — comment-aware arg tokenizer ─────────────────────

    [Fact]
    public void Validate_ArgSplit_LineCommentWithCommaInsideArgs_NoFalsePositive()
    {
        // A `//` line comment containing a comma must NOT be counted as an arg separator.
        // "Target" expects (a: Integer) — one real arg; the comment comma is noise.
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Scripts =
            [
                new ResolvedScript
                {
                    CanonicalName = "Target",
                    Code = "var x = 1;",
                    ParameterDefinitions = "[{\"name\":\"a\",\"type\":\"Integer\"}]"
                },
                new ResolvedScript
                {
                    CanonicalName = "Caller",
                    Code = "CallScript(\"Target\", 42 /* , extra */);"
                }
            ]
        };

        var result = _sut.Validate(config);
        Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch);
    }

    [Fact]
    public void Validate_ArgSplit_BlockCommentWithCommaInsideArgs_NoFalsePositive()
    {
        // A `/* */` block comment containing a comma must NOT be counted as an arg separator.
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Scripts =
            [
                new ResolvedScript
                {
                    CanonicalName = "Target",
                    Code = "var x = 1;",
                    ParameterDefinitions = "[{\"name\":\"a\",\"type\":\"Integer\"},{\"name\":\"b\",\"type\":\"String\"}]"
                },
                new ResolvedScript
                {
                    CanonicalName = "Caller",
                    // Two real args, but the block comment adds a spurious comma if tokenizer is not comment-aware.
                    Code = "CallScript(\"Target\", 42 /* ,bogus */, \"hi\");"
                }
            ]
        };

        var result = _sut.Validate(config);
        Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch);
    }

    // ── M2.7 review nits — stricter numeric-literal inference ───────────────

    [Fact]
    public void Validate_ArgumentType_UnderscoreLeadingIdentifier_NoFalsePositive()
    {
        // `_2` starts with an underscore — it is a C# identifier, not a numeric literal.
        // IsNumericLiteral must return false → type inferred as Unknown → no mismatch.
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Scripts =
            [
                new ResolvedScript
                {
                    CanonicalName = "Target",
                    Code = "var x = 1;",
                    ParameterDefinitions = "[{\"name\":\"a\",\"type\":\"Integer\"}]"
                },
                new ResolvedScript
                {
                    CanonicalName = "Caller",
                    Code = "CallScript(\"Target\", _2);"
                }
            ]
        };

        var result = _sut.Validate(config);
        Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch);
    }

    // ── MV-5 List attribute semantics ────────────────────────────────────────

    [Fact]
    public void Validate_ListAttribute_ValidElementTypeAndDefault_NoError()
    {
        // List + String element + a well-formed JSON-array default — fully valid.
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Attributes =
            [
                new ResolvedAttribute
                {
                    CanonicalName = "Tags",
                    DataType = "List",
                    ElementDataType = "String",
                    Value = "[\"a\",\"b\"]"
                }
            ]
        };

        var result = _sut.Validate(config);
        Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.MissingMetadata);
    }

    [Fact]
    public void Validate_ListAttribute_NullElementType_ReturnsError()
    {
        // List with no element type — rule 1 violation (cardinality).
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Attributes =
            [
                new ResolvedAttribute { CanonicalName = "Tags", DataType = "List", ElementDataType = null }
            ]
        };

        var result = _sut.Validate(config);
        Assert.Contains(result.Errors, e =>
            e.Category == ValidationCategory.MissingMetadata &&
            e.EntityName == "Tags" &&
            e.Message.Contains("element type", StringComparison.OrdinalIgnoreCase));
    }

    [Fact]
    public void Validate_ScalarAttribute_WithElementType_ReturnsError()
    {
        // A non-List attribute must not carry an element type — rule 1 violation.
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Attributes =
            [
                new ResolvedAttribute { CanonicalName = "Status", DataType = "String", ElementDataType = "String" }
            ]
        };

        var result = _sut.Validate(config);
        Assert.Contains(result.Errors, e =>
            e.Category == ValidationCategory.MissingMetadata &&
            e.EntityName == "Status" &&
            e.Message.Contains("element type", StringComparison.OrdinalIgnoreCase));
    }

    [Fact]
    public void Validate_ListAttribute_NonScalarElementType_ReturnsError()
    {
        // Binary and List are not valid List element scalars — rule 1 violation.
        foreach (var bad in new[] { "Binary", "List" })
        {
            var config = new FlattenedConfiguration
            {
                InstanceUniqueName = "Instance1",
                Attributes =
                [
                    new ResolvedAttribute { CanonicalName = "Tags", DataType = "List", ElementDataType = bad }
                ]
            };

            var result = _sut.Validate(config);
            Assert.Contains(result.Errors, e =>
                e.Category == ValidationCategory.MissingMetadata &&
                e.EntityName == "Tags");
        }
    }

    [Fact]
    public void Validate_ListAttribute_MalformedJsonDefault_ReturnsError()
    {
        // Unterminated JSON array default — rule 2 (default parseability) violation.
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Attributes =
            [
                new ResolvedAttribute
                {
                    CanonicalName = "Tags",
                    DataType = "List",
                    ElementDataType = "String",
                    Value = "[\"a\""
                }
            ]
        };

        var result = _sut.Validate(config);
        Assert.Contains(result.Errors, e =>
            e.Category == ValidationCategory.MissingMetadata &&
            e.EntityName == "Tags" &&
            e.Message.Contains("default", StringComparison.OrdinalIgnoreCase));
    }

    [Fact]
    public void Validate_ListAttribute_DefaultElementNotParseable_ReturnsError()
    {
        // Element type Int32 but the default carries a non-integer element — rule 2 violation.
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Attributes =
            [
                new ResolvedAttribute
                {
                    CanonicalName = "Counts",
                    DataType = "List",
                    ElementDataType = "Int32",
                    Value = "[\"x\"]"
                }
            ]
        };

        var result = _sut.Validate(config);
        Assert.Contains(result.Errors, e =>
            e.Category == ValidationCategory.MissingMetadata &&
            e.EntityName == "Counts" &&
            e.Message.Contains("Int32"));
    }

    [Fact]
    public void Validate_ListAttribute_EmptyDefault_NoDefaultError()
    {
        // No authored default → rule 2 is inert; only the (satisfied) cardinality rule applies.
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Attributes =
            [
                new ResolvedAttribute { CanonicalName = "Tags", DataType = "List", ElementDataType = "String", Value = "" }
            ]
        };

        var result = _sut.Validate(config);
        Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.MissingMetadata);
    }

    [Fact]
    public void Validate_ListAttribute_AsRangeViolationOperand_ReturnsError()
    {
        // Rule 3 confirmation: a List attribute is non-numeric, so a RangeViolation
        // trigger over it is rejected by the existing NumericDataTypes guard.
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Attributes =
            [
                new ResolvedAttribute { CanonicalName = "Tags", DataType = "List", ElementDataType = "Int32" }
            ],
            Alarms =
            [
                new ResolvedAlarm
                {
                    CanonicalName = "BadAlarm",
                    TriggerType = "RangeViolation",
                    TriggerConfiguration = "{\"attributeName\":\"Tags\"}"
                }
            ]
        };

        var result = _sut.Validate(config);
        Assert.Contains(result.Errors, e =>
            e.Category == ValidationCategory.TriggerOperandType &&
            e.Message.Contains("non-numeric"));
    }

    [Fact]
    public void Validate_ListAttribute_AsHiLoOperand_ReturnsError()
    {
        // Rule 3 confirmation for HiLo.
        var config = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            Attributes =
            [
                new ResolvedAttribute { CanonicalName = "Tags", DataType = "List", ElementDataType = "Double" }
            ],
            Alarms =
            [
                new ResolvedAlarm
                {
                    CanonicalName = "BadHiLo",
                    TriggerType = "HiLo",
                    TriggerConfiguration = "{\"attributeName\":\"Tags\",\"hi\":80}"
                }
            ]
        };

        var result = _sut.Validate(config);
        Assert.Contains(result.Errors, e =>
            e.Category == ValidationCategory.TriggerOperandType &&
            e.Message.Contains("non-numeric"));
    }
}
