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

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

/// <summary>
/// Pre-deployment validation pipeline. Validates a FlattenedConfiguration for correctness
/// before deployment. Also available on-demand (same logic, no deployment trigger).
///
/// Validation checks:
/// 1. Flattening success (no empty configuration)
/// 2. No naming collisions
/// 3. Script compilation (via ScriptCompiler)
/// 4. Alarm trigger references exist (referenced attributes must be in the flattened config)
/// 5. Script trigger references exist (referenced attributes must be in the flattened config)
/// 6. Expression triggers — blank check, syntax check, and attribute-reference scan
/// 7. Connection binding completeness — every data-sourced attribute must have a binding,
///    and (on the deploy path) the bound connection must exist on the target site.
///    Severity is context-dependent: a non-blocking Warning at template design time
///    (bindings are set later) and a deploy-gating Error when enforced.
/// 8. List-attribute type semantics (via <see cref="SemanticValidator"/>):
///    element-type cardinality, default-value parseability, and trigger-operand
///    rejection for List attributes.
/// 9. Does NOT verify tag path resolution on devices
/// </summary>
public class ValidationService
{
    private readonly SemanticValidator _semanticValidator;
    private readonly ScriptCompiler _scriptCompiler;

    /// <summary>
    /// Initializes a new instance of the ValidationService with the specified dependencies.
    /// </summary>
    /// <param name="semanticValidator">The semantic validator for configuration validation.</param>
    /// <param name="scriptCompiler">The script compiler for validating script code.</param>
    public ValidationService(SemanticValidator semanticValidator, ScriptCompiler scriptCompiler)
    {
        _semanticValidator = semanticValidator;
        _scriptCompiler = scriptCompiler;
    }

    /// <summary>
    /// Convenience constructor that creates default dependencies.
    /// </summary>
    public ValidationService() : this(new SemanticValidator(), new ScriptCompiler())
    {
    }

    /// <summary>
    /// Runs the full validation pipeline on a flattened configuration.
    /// </summary>
    /// <param name="configuration">The flattened configuration to validate.</param>
    /// <param name="sharedScripts">Optional list of shared scripts for validation context.</param>
    /// <param name="alarmCapableConnectionNames">
    /// Optional set of site data-connection names whose protocol resolves to an
    /// alarm-capable adapter (see
    /// <see cref="Commons.Interfaces.Protocol.AlarmCapableProtocols"/>). When supplied,
    /// the semantic validator gates every native-alarm-source binding against it.
    /// <c>null</c> skips the capability check (its absence makes the check inert).
    /// </param>
    /// <param name="enforceConnectionBindings">
    /// Controls the severity of the connection-binding-completeness check.
    /// <para>
    /// <c>false</c> (default) — template DESIGN-TIME: a data-sourced attribute that is
    /// not yet bound produces only a non-blocking <c>Warning</c>. Bindings are set later,
    /// at instance/deploy time, so an unbound data-sourced template attribute is legitimate
    /// here (see <see cref="ManagementService"/>'s ValidateTemplate path, which builds a
    /// config straight from raw template members with no bindings).
    /// </para>
    /// <para>
    /// <c>true</c> — DEPLOY path (<see cref="DeploymentManager"/>'s FlatteningPipeline):
    /// an unbound data-sourced attribute becomes a deploy-gating <c>Error</c> (IsValid false),
    /// and — when <paramref name="siteConnectionNames"/> is supplied — a binding pointing at a
    /// connection that does not exist on the target site is also an <c>Error</c>.
    /// </para>
    /// </param>
    /// <param name="siteConnectionNames">
    /// Optional set of the data-connection names that actually exist on the
    /// target site (computed by the deploy pipeline from the site's loaded connections,
    /// mirroring <paramref name="alarmCapableConnectionNames"/>). When supplied (and
    /// <paramref name="enforceConnectionBindings"/> is <c>true</c>), every bound
    /// connection is checked against this set so a binding to a phantom/stale connection
    /// is caught. <c>null</c> skips the "exists at site" half (it stays inert).
    /// </param>
    /// <param name="resolveSchemaRef">
    /// Optional JSON-Schema <c>$ref</c> resolution seam mapping a
    /// <c>lib:Name</c> reference target's name to the referenced schema JSON (or
    /// <c>null</c> when the library entry does not exist). Supplied on the deploy path
    /// (backed by <c>ISharedSchemaRepository</c>) so a dangling/cyclic/over-depth
    /// <c>$ref</c> in any validated script parameter/return schema becomes a
    /// deploy-blocking <see cref="ValidationCategory.SchemaReference"/> error naming the
    /// missing reference. When <c>null</c> the seam is absent: schemas with no
    /// <c>$ref</c> are unaffected (behavior unchanged), and a <c>$ref</c> with no
    /// resolver is treated as dangling (the safe option).
    /// </param>
    /// <param name="validateScriptCompilation">
    /// <c>true</c> (default) runs the authoritative Roslyn <see cref="ValidateScriptCompilation"/>
    /// stage — the single most expensive check (a non-collectible assembly load per script) —
    /// AND the Expression-trigger syntax/compile check
    /// (<see cref="CheckExpressionSyntax"/>, a forbidden-API verdict plus a real compile of every
    /// Expression-triggered script/alarm body against <see cref="TriggerCompileSurface"/>).
    /// <c>false</c> skips ONLY those two compile stages; every other structural/semantic check
    /// still runs — including, for Expression triggers, the blank-expression check and the
    /// cheap attribute-reference scan. Read-only staleness/comparison callers (DeploymentManager's
    /// FlatteningPipeline on its comparison/probe paths) pass <c>false</c> because they need only
    /// the flattened config and revision hash, not a compile. The deploy gate keeps the default
    /// (<c>true</c>) — it is the authoritative check for both stages.
    /// </param>
    /// <returns>A merged <see cref="ValidationResult"/> aggregating all pipeline stage outcomes.</returns>
    public ValidationResult Validate(
        FlattenedConfiguration configuration,
        IReadOnlyList<ResolvedScript>? sharedScripts = null,
        IReadOnlySet<string>? alarmCapableConnectionNames = null,
        bool enforceConnectionBindings = false,
        IReadOnlySet<string>? siteConnectionNames = null,
        Func<string, string?>? resolveSchemaRef = null,
        bool validateScriptCompilation = true)
    {
        ArgumentNullException.ThrowIfNull(configuration);

        var results = new List<ValidationResult>
        {
            ValidateFlatteningSuccess(configuration),
            ValidateNamingCollisions(configuration),
            // The Roslyn compile is the single most expensive validation stage (a
            // non-collectible InteractiveAssemblyLoader assembly load per script).
            // Read-only staleness/comparison callers pass validateScriptCompilation:
            // false to skip ONLY this stage; every other structural/semantic check
            // still runs. The deploy gate keeps the default (true) — it is the
            // authoritative compile.
            validateScriptCompilation ? ValidateScriptCompilation(configuration) : ValidationResult.Success(),
            ValidateAlarmTriggerReferences(configuration),
            ValidateScriptTriggerReferences(configuration),
            ValidateExpressionTriggers(configuration, validateScriptCompilation),
            ValidateConnectionBindingCompleteness(configuration, enforceConnectionBindings, siteConnectionNames),
            ValidateSchemaReferences(configuration, sharedScripts, resolveSchemaRef),
            _semanticValidator.Validate(configuration, sharedScripts, alarmCapableConnectionNames)
        };

        return ValidationResult.Merge(results.ToArray());
    }

    /// <summary>
    /// JSON-Schema <c>$ref</c> resolution check. Parses every script
    /// parameter/return schema (instance scripts and the supplied shared scripts)
    /// through <see cref="InboundApiSchema.ParseWithRefs"/>, resolving any
    /// <c>{"$ref":"lib:Name"}</c> reference via <paramref name="resolveSchemaRef"/>.
    /// A reference that cannot be resolved — dangling (the seam returns <c>null</c> or
    /// none is supplied), cyclic, or over-depth — is a deploy-blocking
    /// <see cref="ValidationCategory.SchemaReference"/> error naming the missing
    /// reference and the owning script.
    ///
    /// <para>
    /// The check is INERT for schemas that contain no <c>$ref</c>:
    /// <see cref="InboundApiSchema.ParseWithRefs"/> never consults the seam for them and
    /// reports no unresolved refs, so the pre-existing validation behavior is unchanged
    /// (this is the only edit to the schema validation path for non-<c>$ref</c> schemas).
    /// A malformed (non-JSON) schema is left to the existing script-compilation /
    /// semantic checks — this check swallows the parse exception and reports nothing,
    /// so it never double-reports a structural problem as a missing reference.
    /// </para>
    /// </summary>
    /// <param name="configuration">The flattened configuration whose scripts' schemas are checked.</param>
    /// <param name="sharedScripts">Optional shared scripts whose schemas are also checked.</param>
    /// <param name="resolveSchemaRef">The <c>$ref</c> resolution seam, or <c>null</c> (no resolver → refs dangle).</param>
    /// <returns>A <see cref="ValidationResult"/> with one error per unresolved reference, or success.</returns>
    internal static ValidationResult ValidateSchemaReferences(
        FlattenedConfiguration configuration,
        IReadOnlyList<ResolvedScript>? sharedScripts,
        Func<string, string?>? resolveSchemaRef)
    {
        var errors = new List<ValidationEntry>();

        foreach (var script in configuration.Scripts)
        {
            CheckSchema(script.CanonicalName, "parameter definitions", script.ParameterDefinitions);
            CheckSchema(script.CanonicalName, "return definition", script.ReturnDefinition);
        }

        foreach (var shared in sharedScripts ?? [])
        {
            CheckSchema(shared.CanonicalName, "parameter definitions", shared.ParameterDefinitions);
            CheckSchema(shared.CanonicalName, "return definition", shared.ReturnDefinition);
        }

        return errors.Count > 0
            ? new ValidationResult { Errors = errors }
            : ValidationResult.Success();

        void CheckSchema(string scriptName, string schemaLabel, string? schemaJson)
        {
            if (string.IsNullOrWhiteSpace(schemaJson))
                return;

            SchemaParseResult parsed;
            try
            {
                parsed = InboundApiSchema.ParseWithRefs(schemaJson, resolveSchemaRef);
            }
            catch (JsonException)
            {
                // Malformed schema JSON / over-depth nesting is surfaced by the other
                // validation stages — not a missing-reference concern. Don't double-report.
                return;
            }

            foreach (var missing in parsed.UnresolvedReferences)
            {
                // Keep the lib:-qualified pointer name SEPARATE from the diagnostic
                // reason so the message reads "schema 'lib:Foo' could not be resolved
                // (cyclic reference)" rather than embedding the annotation inside the
                // lib:-looking string.
                var reasonSuffix = missing.Reason is null ? string.Empty : $" ({missing.Reason})";
                errors.Add(ValidationEntry.Error(ValidationCategory.SchemaReference,
                    $"Script '{scriptName}' {schemaLabel} references schema 'lib:{missing.Name}' which could not be resolved{reasonSuffix}.",
                    scriptName));
            }
        }
    }

    /// <summary>
    /// Validates that flattening produced a non-empty configuration.
    /// </summary>
    /// <param name="configuration">The flattened configuration to validate.</param>
    /// <returns>A <see cref="ValidationResult"/> with errors or warnings if the configuration is empty or missing a name; otherwise success.</returns>
    public static ValidationResult ValidateFlatteningSuccess(FlattenedConfiguration configuration)
    {
        var errors = new List<ValidationEntry>();

        if (string.IsNullOrWhiteSpace(configuration.InstanceUniqueName))
            errors.Add(ValidationEntry.Error(ValidationCategory.FlatteningFailure,
                "Instance unique name is missing."));

        if (configuration.Attributes.Count == 0 &&
            configuration.Alarms.Count == 0 &&
            configuration.Scripts.Count == 0)
        {
            return new ValidationResult
            {
                Warnings = [ValidationEntry.Warning(ValidationCategory.FlatteningFailure,
                    "Flattened configuration contains no attributes, alarms, or scripts.")]
            };
        }

        return errors.Count > 0
            ? new ValidationResult { Errors = errors }
            : ValidationResult.Success();
    }

    /// <summary>
    /// Validates that there are no naming collisions across entity types.
    /// Canonical names must be unique within their entity type (attributes, alarms, scripts).
    /// </summary>
    /// <param name="configuration">The flattened configuration to validate.</param>
    /// <returns>A <see cref="ValidationResult"/> with errors for each duplicate canonical name, or success.</returns>
    public static ValidationResult ValidateNamingCollisions(FlattenedConfiguration configuration)
    {
        var errors = new List<ValidationEntry>();

        CheckDuplicates(configuration.Attributes, a => a.CanonicalName, "Attribute", errors);
        CheckDuplicates(configuration.Alarms, a => a.CanonicalName, "Alarm", errors);
        CheckDuplicates(configuration.Scripts, s => s.CanonicalName, "Script", errors);

        return errors.Count > 0
            ? new ValidationResult { Errors = errors }
            : ValidationResult.Success();
    }

    /// <summary>
    /// Validates that all scripts compile successfully using the ScriptCompiler.
    /// </summary>
    /// <param name="configuration">The flattened configuration to validate.</param>
    /// <returns>A <see cref="ValidationResult"/> with errors for each script that fails compilation.</returns>
    public ValidationResult ValidateScriptCompilation(FlattenedConfiguration configuration)
    {
        var errors = new List<ValidationEntry>();
        var warnings = new List<ValidationEntry>();

        foreach (var script in configuration.Scripts)
        {
            var result = _scriptCompiler.TryCompile(script.Code, script.CanonicalName);
            if (!result.IsSuccess)
            {
                errors.Add(ValidationEntry.Error(ValidationCategory.ScriptCompilation,
                    $"Script '{script.CanonicalName}' failed compilation: {result.Error}",
                    script.CanonicalName));
            }
        }

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

    /// <summary>
    /// Validates that alarm trigger configurations reference existing attributes.
    /// Alarm trigger configs are JSON with an "attributeName" field referencing a canonical attribute name.
    /// </summary>
    /// <param name="configuration">The flattened configuration to validate.</param>
    /// <returns>A <see cref="ValidationResult"/> with errors for any alarm whose trigger references a missing attribute.</returns>
    public static ValidationResult ValidateAlarmTriggerReferences(FlattenedConfiguration configuration)
    {
        var errors = new List<ValidationEntry>();
        var attributeNames = new HashSet<string>(
            configuration.Attributes.Select(a => a.CanonicalName), StringComparer.Ordinal);

        foreach (var alarm in configuration.Alarms)
        {
            if (string.IsNullOrWhiteSpace(alarm.TriggerConfiguration))
                continue;

            var attrName = ExtractAttributeNameFromTriggerConfig(alarm.TriggerConfiguration);
            if (attrName != null && !attributeNames.Contains(attrName))
            {
                errors.Add(ValidationEntry.Error(ValidationCategory.AlarmTriggerReference,
                    $"Alarm '{alarm.CanonicalName}' references attribute '{attrName}' which does not exist in the flattened configuration.",
                    alarm.CanonicalName));
            }
        }

        return errors.Count > 0
            ? new ValidationResult { Errors = errors }
            : ValidationResult.Success();
    }

    /// <summary>
    /// Validates that script trigger configurations reference existing attributes.
    /// </summary>
    /// <param name="configuration">The flattened configuration to validate.</param>
    /// <returns>A <see cref="ValidationResult"/> with errors for any script whose trigger references a missing attribute.</returns>
    public static ValidationResult ValidateScriptTriggerReferences(FlattenedConfiguration configuration)
    {
        var errors = new List<ValidationEntry>();
        var attributeNames = new HashSet<string>(
            configuration.Attributes.Select(a => a.CanonicalName), StringComparer.Ordinal);

        foreach (var script in configuration.Scripts)
        {
            if (string.IsNullOrWhiteSpace(script.TriggerConfiguration))
                continue;

            var attrName = ExtractAttributeNameFromTriggerConfig(script.TriggerConfiguration);
            if (attrName != null && !attributeNames.Contains(attrName))
            {
                errors.Add(ValidationEntry.Error(ValidationCategory.ScriptTriggerReference,
                    $"Script '{script.CanonicalName}' trigger references attribute '{attrName}' which does not exist in the flattened configuration.",
                    script.CanonicalName));
            }
        }

        return errors.Count > 0
            ? new ValidationResult { Errors = errors }
            : ValidationResult.Success();
    }

    /// <summary>
    /// Validates Expression-trigger scripts and alarms before deployment.
    ///
    /// For every script/alarm whose trigger type is "Expression" this performs three
    /// checks against the <c>{ "expression": "..." }</c> trigger configuration:
    /// <list type="bullet">
    ///   <item>Blank expression → warning (the trigger will never fire).</item>
    ///   <item>Syntax/compile check → error if the expression uses a forbidden API
    ///     or does not compile as a bare boolean expression. Delegates to the shared
    ///     authoritative analyzer (see <see cref="ScriptCompiler"/> and
    ///     <see cref="CheckExpressionSyntax"/>) — a real Roslyn compile against the
    ///     <see cref="TriggerCompileSurface"/>, not a string scan.</item>
    ///   <item>Attribute-reference scan → error for any <c>Attributes["X"]</c> literal
    ///     whose key is absent from the flattened configuration, mirroring
    ///     <see cref="ValidateScriptTriggerReferences"/> for the structured triggers.</item>
    /// </list>
    /// </summary>
    /// <param name="configuration">The flattened configuration to validate.</param>
    /// <param name="validateExpressionSyntax">
    /// <c>true</c> (default) runs the authoritative <see cref="CheckExpressionSyntax"/>
    /// forbidden-API + compile stage for each Expression trigger. <c>false</c> skips ONLY
    /// that stage — the blank-expression check and the attribute-reference scan still run —
    /// so read-only staleness/comparison callers do not pay the per-expression compile.
    /// </param>
    /// <returns>A <see cref="ValidationResult"/> with errors and warnings from all expression trigger checks.</returns>
    public static ValidationResult ValidateExpressionTriggers(
        FlattenedConfiguration configuration,
        bool validateExpressionSyntax = true)
    {
        var errors = new List<ValidationEntry>();
        var warnings = new List<ValidationEntry>();
        var attributeNames = new HashSet<string>(
            configuration.Attributes.Select(a => a.CanonicalName), StringComparer.Ordinal);

        foreach (var script in configuration.Scripts)
        {
            if (!IsExpressionTrigger(script.TriggerType))
                continue;

            CheckExpressionTrigger(
                ValidationCategory.ScriptTriggerReference, "script",
                script.CanonicalName, script.TriggerConfiguration,
                attributeNames, errors, warnings, validateExpressionSyntax);
        }

        foreach (var alarm in configuration.Alarms)
        {
            if (!IsExpressionTrigger(alarm.TriggerType))
                continue;

            CheckExpressionTrigger(
                ValidationCategory.AlarmTriggerReference, "alarm",
                alarm.CanonicalName, alarm.TriggerConfiguration,
                attributeNames, errors, warnings, validateExpressionSyntax);
        }

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

    private static bool IsExpressionTrigger(string? triggerType) =>
        string.Equals(triggerType, "Expression", StringComparison.OrdinalIgnoreCase);

    /// <summary>
    /// Runs the blank / syntax / attribute-reference checks for a single
    /// Expression-trigger entity and appends any findings to the shared lists.
    /// </summary>
    /// <param name="category">
    /// The <see cref="ValidationCategory"/> to file every finding under
    /// (<see cref="ValidationCategory.ScriptTriggerReference"/> for scripts,
    /// <see cref="ValidationCategory.AlarmTriggerReference"/> for alarms). The same
    /// category is used for blank, syntax, and attribute-reference findings so an
    /// alarm's syntax error is not miscategorised as script compilation.
    /// </param>
    /// <param name="entityLabel">
    /// Human-readable entity-type label (<c>"script"</c>/<c>"alarm"</c>) used in
    /// message text only.
    /// </param>
    private static void CheckExpressionTrigger(
        ValidationCategory category,
        string entityLabel,
        string entityName,
        string? triggerConfigJson,
        HashSet<string> attributeNames,
        List<ValidationEntry> errors,
        List<ValidationEntry> warnings,
        bool validateExpressionSyntax = true)
    {
        var expression = ExtractExpressionFromTriggerConfig(triggerConfigJson);
        var strict = IsStrictAnalysis(triggerConfigJson);

        if (string.IsNullOrWhiteSpace(expression))
        {
            // The blank-expression finding is the only advisory one. Under the
            // default Advisory kind it stays a non-blocking warning (it merely never fires);
            // under Strict it is promoted to a deploy-blocking error. Pure escalation —
            // the message and category are unchanged.
            var message = $"The {entityLabel} '{entityName}' has an expression trigger with no expression; it will never fire.";
            if (strict)
                errors.Add(ValidationEntry.Error(category, message, entityName));
            else
                warnings.Add(ValidationEntry.Warning(category, message, entityName));
            return;
        }

        // The syntax/compile stage is the expensive one (a forbidden-API verdict + a
        // real Roslyn compile of the expression). Read-only staleness/comparison
        // callers pass validateExpressionSyntax: false to skip ONLY this stage; the
        // blank-expression check above and the attribute-reference scan below still run.
        // The deploy gate keeps the default (true) — the authoritative check.
        if (validateExpressionSyntax)
        {
            var syntaxError = CheckExpressionSyntax(expression);
            if (syntaxError != null)
            {
                errors.Add(ValidationEntry.Error(category,
                    $"The {entityLabel} '{entityName}' expression trigger failed validation: {syntaxError}",
                    entityName));
            }
        }

        foreach (var attrName in ExtractAttributeReferences(expression))
        {
            if (!attributeNames.Contains(attrName))
            {
                errors.Add(ValidationEntry.Error(category,
                    $"The {entityLabel} '{entityName}' expression trigger references attribute '{attrName}' which does not exist in the flattened configuration.",
                    entityName));
            }
        }
    }

    /// <summary>
    /// Reads the "expression" string from a <c>{ "expression": "..." }</c> trigger
    /// configuration. Returns <c>null</c> on malformed JSON or a missing key.
    /// </summary>
    /// <param name="triggerConfigJson">The trigger configuration JSON to parse.</param>
    /// <returns>The expression string, or <c>null</c> if absent or the JSON is malformed.</returns>
    internal static string? ExtractExpressionFromTriggerConfig(string? triggerConfigJson)
    {
        if (string.IsNullOrWhiteSpace(triggerConfigJson))
            return null;
        try
        {
            using var doc = JsonDocument.Parse(triggerConfigJson);
            if (doc.RootElement.TryGetProperty("expression", out var prop)
                && prop.ValueKind == JsonValueKind.String)
            {
                return prop.GetString();
            }
        }
        catch (JsonException)
        {
            // Not valid JSON — treated as a blank expression by the caller.
        }
        return null;
    }

    /// <summary>
    /// Reads the optional <c>"analysisKind"</c> discriminator from a trigger
    /// configuration. <c>true</c> only when the value is the case-insensitive
    /// literal <c>"Strict"</c>; everything else — a missing key, the literal
    /// <c>"Advisory"</c>, any other value, or malformed JSON — defaults to Advisory
    /// (<c>false</c>), preserving today's behavior exactly.
    /// </summary>
    /// <param name="triggerConfigJson">The trigger configuration JSON to parse.</param>
    /// <returns><c>true</c> when the configured analysis kind is Strict; otherwise <c>false</c> (Advisory).</returns>
    internal static bool IsStrictAnalysis(string? triggerConfigJson)
    {
        if (string.IsNullOrWhiteSpace(triggerConfigJson))
            return false;
        try
        {
            using var doc = JsonDocument.Parse(triggerConfigJson);
            if (doc.RootElement.TryGetProperty("analysisKind", out var prop)
                && prop.ValueKind == JsonValueKind.String)
            {
                return string.Equals(prop.GetString(), "Strict", StringComparison.OrdinalIgnoreCase);
            }
        }
        catch (JsonException)
        {
            // Not valid JSON — Advisory (no escalation), matching the blank-expression default.
        }
        return false;
    }

    /// <summary>
    /// Authoritative syntax/trust check for a trigger expression. Delegates to the
    /// shared <see cref="ZB.MOM.WW.ScadaBridge.ScriptAnalysis"/> analyzer (same gate
    /// as <see cref="ScriptCompiler"/>): a real forbidden-API verdict
    /// (<see cref="ScriptTrustValidator.FindViolations(string, System.Collections.Generic.IEnumerable{Microsoft.CodeAnalysis.MetadataReference})"/>)
    /// followed by a real CSharpScript compile of the bare boolean expression against
    /// the <see cref="TriggerCompileSurface"/> globals. Returns an error message, or
    /// <c>null</c> when the expression is clean and compiles.
    /// </summary>
    /// <param name="expression">The expression to check.</param>
    /// <returns>A human-readable error message if the expression is invalid; <c>null</c> if well-formed.</returns>
    internal static string? CheckExpressionSyntax(string expression)
    {
        // Memoise the verdict under the TRIGGER surface key (see ScriptCompileVerdictCache):
        // the verdict is a pure function of expression + policy + TriggerCompileSurface, and
        // it is the exact hot-path cost N1 flagged — every staleness sweep / import probe of
        // an Expression-triggered config re-ran a full trust compilation + script compile.
        var (_, error) = ScriptCompileVerdictCache.GetOrAdd(nameof(TriggerCompileSurface), expression, () =>
        {
            // Authoritative forbidden-API verdict first. Report ALL violations, not
            // just the first, so an operator fixing one forbidden API isn't surprised
            // by a second on the next deploy attempt (mirrors ScriptCompiler.TryCompile).
            var violations = ScriptTrustValidator.FindViolations(expression);
            if (violations.Count > 0)
                return (false, $"uses forbidden API: {string.Join("; ", violations)}");

            // Real compile of the bare boolean expression against the trigger globals.
            var errors = RoslynScriptCompiler.Compile(expression, typeof(TriggerCompileSurface));
            if (errors.Count > 0)
                return (false, $"is not a valid expression: {string.Join("; ", errors)}");

            return (true, (string?)null);
        });
        return error;
    }

    /// <summary>
    /// Scans an expression for <c>Attributes["..."]</c> string-literal accessor keys.
    /// Best-effort: only matches double-quoted literals (the form the editor emits)
    /// and skips keys built dynamically.
    /// </summary>
    /// <param name="expression">The expression to scan for attribute references.</param>
    /// <returns>The distinct attribute key strings found in self-attribute accessor positions.</returns>
    internal static IEnumerable<string> ExtractAttributeReferences(string expression)
    {
        var seen = new HashSet<string>(StringComparer.Ordinal);
        const string marker = "Attributes[";
        var index = 0;

        while ((index = expression.IndexOf(marker, index, StringComparison.Ordinal)) >= 0)
        {
            // Only treat this as a self-attribute reference when it is not a member
            // access. A bare `Attributes["X"]` resolves against the flattened
            // configuration; `Children["Pump"].Attributes["X"]` and
            // `Parent.Attributes["X"]` are member accesses (preceded by '.') whose
            // dotted/composed canonical names cannot be checked against the flat
            // self-attribute set — skip them rather than emit a false positive.
            if (index > 0 && expression[index - 1] == '.')
            {
                index += marker.Length;
                continue;
            }

            var cursor = index + marker.Length;
            // Skip whitespace between '[' and the literal.
            while (cursor < expression.Length && char.IsWhiteSpace(expression[cursor]))
                cursor++;

            if (cursor < expression.Length && expression[cursor] == '"')
            {
                var keyStart = cursor + 1;
                var keyEnd = keyStart;
                while (keyEnd < expression.Length && expression[keyEnd] != '"')
                {
                    if (expression[keyEnd] == '\\') keyEnd++; // skip escaped char
                    keyEnd++;
                }

                if (keyEnd < expression.Length)
                {
                    var key = expression.Substring(keyStart, keyEnd - keyStart);
                    if (key.Length > 0 && seen.Add(key))
                        yield return key;
                }
            }

            index += marker.Length;
        }
    }

    /// <summary>
    /// Validates connection bindings on data-sourced attributes. Only DATA-SOURCED
    /// attributes (<see cref="ResolvedAttribute.DataSourceReference"/> != <c>null</c>)
    /// require a binding; static attributes are never flagged.
    ///
    /// The severity is context-dependent (see <paramref name="enforce"/>).
    /// At template design time (<c>enforce == false</c>) an unbound data-sourced
    /// attribute is legitimate (bindings are set later) so it is only a non-blocking
    /// <c>Warning</c>. On the deploy path (<c>enforce == true</c>) an unbound
    /// data-sourced attribute is a deploy-gating <c>Error</c>, and — when
    /// <paramref name="siteConnectionNames"/> is supplied — a binding to a connection
    /// that does not exist on the target site is also an <c>Error</c>.
    /// </summary>
    /// <param name="configuration">The flattened configuration to validate.</param>
    /// <param name="enforce">
    /// <c>true</c> on the deploy path (unbound → Error + "exists at site" check);
    /// <c>false</c> at design time (unbound → Warning only). Defaults to <c>false</c>
    /// so design-time validation stays non-blocking.
    /// </param>
    /// <param name="siteConnectionNames">
    /// Optional set of data-connection names that actually exist on the target site.
    /// When non-<c>null</c> and <paramref name="enforce"/> is <c>true</c>, every bound
    /// connection name is checked against this set. <c>null</c> skips the "exists at
    /// site" check.
    /// </param>
    /// <returns>A <see cref="ValidationResult"/> with the binding findings at the appropriate severity.</returns>
    public static ValidationResult ValidateConnectionBindingCompleteness(
        FlattenedConfiguration configuration,
        bool enforce = false,
        IReadOnlySet<string>? siteConnectionNames = null)
    {
        var errors = new List<ValidationEntry>();
        var warnings = new List<ValidationEntry>();

        foreach (var attr in configuration.Attributes)
        {
            // Only data-sourced attributes participate in binding validation.
            if (attr.DataSourceReference == null)
                continue;

            if (attr.BoundDataConnectionId == null)
            {
                // Unbound data-sourced attribute. At deploy time this gates the
                // deployment; at design time the binding is set later, so it is
                // only advisory.
                //
                // NOTE: this branch fires for TWO distinct cases that are
                // indistinguishable post-flattening:
                //   1. The user genuinely never set a binding.
                //   2. The user set a binding, but FlatteningService.ApplyConnectionBindings
                //      silently dropped it because the stored DataConnectionId no longer
                //      resolves to any loaded site DataConnection (i.e. the connection was
                //      deleted after the binding was created). In that case the flattener
                //      leaves BoundDataConnectionId == null, and the attribute falls into
                //      this same "unbound → Error" path.
                // The error message covers both cases; no behavioral change is needed.
                if (enforce)
                {
                    errors.Add(ValidationEntry.Error(ValidationCategory.ConnectionBinding,
                        $"Attribute '{attr.CanonicalName}' has a data source reference but no connection binding.",
                        attr.CanonicalName));
                }
                else
                {
                    warnings.Add(ValidationEntry.Warning(ValidationCategory.ConnectionBinding,
                        $"Attribute '{attr.CanonicalName}' has a data source reference but no connection binding.",
                        attr.CanonicalName));
                }
                // Skip the "exists at site" check below — it only applies to bound attributes.
                continue;
            }

            // The attribute IS bound. On the deploy path, verify the bound connection
            // actually exists on the target site (resolve against the site's connection
            // set, not just name presence in the config). A binding pointing at a
            // non-existent/stale site connection is a deploy-gating Error.
            if (enforce && siteConnectionNames != null &&
                attr.BoundDataConnectionName != null &&
                !siteConnectionNames.Contains(attr.BoundDataConnectionName))
            {
                errors.Add(ValidationEntry.Error(ValidationCategory.ConnectionBinding,
                    $"Attribute '{attr.CanonicalName}' is bound to data connection '{attr.BoundDataConnectionName}' " +
                    "which does not exist on the target site.",
                    attr.CanonicalName));
            }
        }

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

    private static void CheckDuplicates<T>(
        IReadOnlyList<T> items,
        Func<T, string> getName,
        string entityType,
        List<ValidationEntry> errors)
    {
        var seen = new HashSet<string>(StringComparer.Ordinal);
        foreach (var item in items)
        {
            var name = getName(item);
            if (!seen.Add(name))
            {
                errors.Add(ValidationEntry.Error(ValidationCategory.NamingCollision,
                    $"{entityType} naming collision: '{name}' appears more than once.",
                    name));
            }
        }
    }

    /// <summary>
    /// Extracts the attribute name from a trigger configuration JSON.
    /// </summary>
    /// <param name="triggerConfigJson">The trigger configuration JSON to parse.</param>
    /// <returns>The attribute name from the <c>attributeName</c> or legacy <c>attribute</c> key, or <c>null</c> if absent or malformed.</returns>
    internal static string? ExtractAttributeNameFromTriggerConfig(string triggerConfigJson)
    {
        // Accept both keys to stay consistent with FlatteningService.PrefixTriggerAttribute,
        // AlarmActor.ParseEvalConfig and AlarmTriggerConfigCodec. Old data may still use
        // "attribute"; the UI codec writes the canonical "attributeName".
        try
        {
            using var doc = JsonDocument.Parse(triggerConfigJson);
            if (doc.RootElement.TryGetProperty("attributeName", out var prop))
                return prop.GetString();
            if (doc.RootElement.TryGetProperty("attribute", out var legacyProp))
                return legacyProp.GetString();
        }
        catch (JsonException)
        {
            // Not valid JSON, ignore
        }
        return null;
    }

    /// <summary>
    /// Extracts the four HiLo setpoints from a trigger configuration JSON.
    /// Any unset (or non-numeric) setpoint comes back as <c>null</c>. Returns
    /// all-nulls on malformed JSON — callers should treat that as "nothing to
    /// validate" and let other checks surface the deeper problem.
    /// </summary>
    /// <param name="triggerConfigJson">The trigger configuration JSON to parse.</param>
    /// <returns>A <see cref="HiLoSetpoints"/> record with the parsed setpoint values; any missing or non-numeric value is <c>null</c>.</returns>
    internal static HiLoSetpoints ExtractHiLoSetpoints(string triggerConfigJson)
    {
        try
        {
            using var doc = JsonDocument.Parse(triggerConfigJson);
            var root = doc.RootElement;
            return new HiLoSetpoints(
                LoLo: ReadDouble(root, "loLo"),
                Lo:   ReadDouble(root, "lo"),
                Hi:   ReadDouble(root, "hi"),
                HiHi: ReadDouble(root, "hiHi"),
                LoLoDeadband: ReadDouble(root, "loLoDeadband"),
                LoDeadband:   ReadDouble(root, "loDeadband"),
                HiDeadband:   ReadDouble(root, "hiDeadband"),
                HiHiDeadband: ReadDouble(root, "hiHiDeadband"));
        }
        catch (JsonException)
        {
            return new HiLoSetpoints(null, null, null, null, null, null, null, null);
        }
    }

    private static double? ReadDouble(JsonElement el, string name)
    {
        if (!el.TryGetProperty(name, out var p)) return null;
        return p.ValueKind switch
        {
            JsonValueKind.Number => p.GetDouble(),
            JsonValueKind.String when double.TryParse(p.GetString(),
                System.Globalization.NumberStyles.Float,
                System.Globalization.CultureInfo.InvariantCulture, out var v) => v,
            _ => null
        };
    }
}

internal readonly record struct HiLoSetpoints(
    double? LoLo, double? Lo, double? Hi, double? HiHi,
    double? LoLoDeadband = null, double? LoDeadband = null,
    double? HiDeadband = null, double? HiHiDeadband = null);
