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

namespace ScadaLink.TemplateEngine.Validation;

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

    /// <summary>
    /// Runs all semantic validation rules.
    /// </summary>
    /// <param name="configuration">The flattened configuration to validate.</param>
    /// <param name="sharedScripts">Shared scripts available for CallShared references.</param>
    public ValidationResult Validate(
        FlattenedConfiguration configuration,
        IReadOnlyList<ResolvedScript>? sharedScripts = null)
    {
        var errors = new List<ValidationEntry>();
        var warnings = new List<ValidationEntry>();

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

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

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

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

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

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

            foreach (var call in callTargets)
            {
                if (call.IsShared)
                {
                    // CallShared targets must reference existing shared scripts
                    if (!sharedScriptNames.Contains(call.TargetName))
                    {
                        errors.Add(ValidationEntry.Error(ValidationCategory.CallTargetNotFound,
                            $"Script '{script.CanonicalName}' calls shared script '{call.TargetName}' which does not exist.",
                            script.CanonicalName));
                    }
                    else
                    {
                        ValidateCallParameters(script.CanonicalName, call, sharedParamMap, errors);
                    }
                }
                else
                {
                    // CallScript targets must reference existing instance scripts
                    if (!scriptNames.Contains(call.TargetName))
                    {
                        errors.Add(ValidationEntry.Error(ValidationCategory.CallTargetNotFound,
                            $"Script '{script.CanonicalName}' calls script '{call.TargetName}' which does not exist.",
                            script.CanonicalName));
                    }
                    else
                    {
                        ValidateCallParameters(script.CanonicalName, call, scriptParamMap, errors);

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

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

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

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

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

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

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

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

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

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

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

        if (call.ArgumentCount != expectedParams.Count)
        {
            errors.Add(ValidationEntry.Error(ValidationCategory.ParameterMismatch,
                $"Script '{callerName}' calls '{call.TargetName}' with {call.ArgumentCount} arguments but {expectedParams.Count} are expected.",
                callerName));
        }
    }

    private static Dictionary<string, List<string>> BuildParameterMap(IReadOnlyList<ResolvedScript> scripts)
    {
        var result = new Dictionary<string, List<string>>(StringComparer.Ordinal);
        foreach (var script in scripts)
        {
            var parameters = ParseParameterDefinitions(script.ParameterDefinitions);
            result[script.CanonicalName] = parameters;
        }
        return result;
    }

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

    internal static List<string> ParseParameterDefinitions(string? parameterDefinitionsJson)
    {
        if (string.IsNullOrWhiteSpace(parameterDefinitionsJson))
            return [];

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

        return [];
    }

    /// <summary>
    /// Extracts call targets from script code by simple pattern matching.
    /// Looks for CallScript("name", ...) and CallShared("name", ...) patterns.
    /// </summary>
    internal static List<CallTarget> ExtractCallTargets(string code)
    {
        var results = new List<CallTarget>();

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

        return results;
    }

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

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

            var argsStart = idx + searchPattern.Length;
            var target = ExtractStringArgument(code, argsStart);
            if (target != null)
            {
                var argCount = CountArguments(code, argsStart);
                results.Add(new CallTarget
                {
                    TargetName = target,
                    IsShared = isShared,
                    ArgumentCount = Math.Max(0, argCount - 1) // First arg is the name, rest are parameters
                });
            }

            pos = argsStart;
        }
    }

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

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

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

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

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

        return code[nameStart..pos];
    }

    private static int CountArguments(string code, int startPos)
    {
        var depth = 1;
        var count = 1; // At least one argument (the name)
        var pos = startPos;

        while (pos < code.Length && depth > 0)
        {
            switch (code[pos])
            {
                case '(':
                    depth++;
                    break;
                case ')':
                    depth--;
                    break;
                case ',' when depth == 1:
                    count++;
                    break;
                case '"':
                case '\'':
                    // Skip string literals
                    var quote = code[pos];
                    pos++;
                    while (pos < code.Length && code[pos] != quote)
                    {
                        if (code[pos] == '\\') pos++; // Skip escaped chars
                        pos++;
                    }
                    break;
            }
            pos++;
        }

        return count;
    }

    internal record CallTarget
    {
        public string TargetName { get; init; } = string.Empty;
        public bool IsShared { get; init; }
        public int ArgumentCount { get; init; }
    }
}
