using System.Text.Json;
using ScadaLink.Commons.Types.Flattening;

namespace ScadaLink.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 (all data-sourced attributes must have a binding)
/// 8. Does NOT verify tag path resolution on devices
/// </summary>
public class ValidationService
{
    private readonly SemanticValidator _semanticValidator;
    private readonly ScriptCompiler _scriptCompiler;

    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>
    public ValidationResult Validate(FlattenedConfiguration configuration, IReadOnlyList<ResolvedScript>? sharedScripts = null)
    {
        ArgumentNullException.ThrowIfNull(configuration);

        var results = new List<ValidationResult>
        {
            ValidateFlatteningSuccess(configuration),
            ValidateNamingCollisions(configuration),
            ValidateScriptCompilation(configuration),
            ValidateAlarmTriggerReferences(configuration),
            ValidateScriptTriggerReferences(configuration),
            ValidateExpressionTriggers(configuration),
            ValidateConnectionBindingCompleteness(configuration),
            _semanticValidator.Validate(configuration, sharedScripts)
        };

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

    /// <summary>
    /// Validates that flattening produced a non-empty configuration.
    /// </summary>
    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>
    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>
    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>
    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>
    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 check → error if the expression uses a forbidden API or has
    ///     unbalanced brackets/quotes. The TemplateEngine project does not reference a
    ///     Roslyn compiler (see <see cref="ScriptCompiler"/>), so this mirrors that
    ///     string-based syntax check rather than a full compile.</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>
    public static ValidationResult ValidateExpressionTriggers(FlattenedConfiguration configuration)
    {
        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);
        }

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

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

        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)
    {
        var expression = ExtractExpressionFromTriggerConfig(triggerConfigJson);

        if (string.IsNullOrWhiteSpace(expression))
        {
            warnings.Add(ValidationEntry.Warning(category,
                $"The {entityLabel} '{entityName}' has an expression trigger with no expression; it will never fire.",
                entityName));
            return;
        }

        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>
    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>
    /// Lightweight string-based syntax check for a trigger expression. Mirrors the
    /// approach in <see cref="ScriptCompiler"/> (the TemplateEngine project has no
    /// Roslyn compiler reference): rejects forbidden APIs and unbalanced
    /// brackets/quotes. Returns an error message, or <c>null</c> when the expression
    /// looks well-formed.
    /// </summary>
    internal static string? CheckExpressionSyntax(string expression)
    {
        foreach (var pattern in ScriptCompiler.ForbiddenPatterns)
        {
            if (expression.Contains(pattern, StringComparison.Ordinal))
            {
                return $"uses forbidden API '{pattern.TrimEnd('.')}'. " +
                       "Trigger expressions cannot use System.IO, Process, Threading, Reflection, or raw network APIs.";
            }
        }

        var parenDepth = 0;
        var bracketDepth = 0;
        var braceDepth = 0;
        var inString = false;
        var inChar = false;
        var inLineComment = false;
        var inBlockComment = false;

        for (int i = 0; i < expression.Length; i++)
        {
            var c = expression[i];
            var next = i + 1 < expression.Length ? expression[i + 1] : '\0';

            if (inLineComment)
            {
                if (c == '\n') inLineComment = false;
                continue;
            }

            if (inBlockComment)
            {
                if (c == '*' && next == '/')
                {
                    inBlockComment = false;
                    i++;
                }
                continue;
            }

            if (inString)
            {
                if (c == '\\') { i++; continue; }
                if (c == '"') inString = false;
                continue;
            }

            if (inChar)
            {
                if (c == '\\') { i++; continue; }
                if (c == '\'') inChar = false;
                continue;
            }

            if (c == '/' && next == '/')
            {
                inLineComment = true;
                i++;
                continue;
            }

            if (c == '/' && next == '*')
            {
                inBlockComment = true;
                i++;
                continue;
            }

            switch (c)
            {
                case '"': inString = true; break;
                case '\'': inChar = true; break;
                case '(': parenDepth++; break;
                case ')':
                    parenDepth--;
                    if (parenDepth < 0) return "mismatched parentheses (unexpected ')').";
                    break;
                case '[': bracketDepth++; break;
                case ']':
                    bracketDepth--;
                    if (bracketDepth < 0) return "mismatched brackets (unexpected ']').";
                    break;
                case '{': braceDepth++; break;
                case '}':
                    braceDepth--;
                    if (braceDepth < 0) return "mismatched braces (unexpected '}').";
                    break;
            }
        }

        if (inBlockComment) return "unterminated block comment.";
        if (inString) return "unterminated string literal.";
        if (inChar) return "unterminated character literal.";
        if (parenDepth != 0) return $"mismatched parentheses ({parenDepth} unclosed).";
        if (bracketDepth != 0) return $"mismatched brackets ({bracketDepth} unclosed).";
        if (braceDepth != 0) return $"mismatched braces ({braceDepth} unclosed).";

        return null;
    }

    /// <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>
    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 that all data-sourced attributes have connection bindings.
    /// </summary>
    public static ValidationResult ValidateConnectionBindingCompleteness(FlattenedConfiguration configuration)
    {
        var errors = new List<ValidationEntry>();
        var warnings = new List<ValidationEntry>();

        foreach (var attr in configuration.Attributes)
        {
            if (attr.DataSourceReference != null && attr.BoundDataConnectionId == null)
            {
                warnings.Add(ValidationEntry.Warning(ValidationCategory.ConnectionBinding,
                    $"Attribute '{attr.CanonicalName}' has a data source reference but no connection binding.",
                    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));
            }
        }
    }

    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>
    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);
