using System.Text.Json;

namespace ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;

/// <summary>
/// Recursive, persistence-ignorant model of an inbound-API parameter or
/// return-value type definition. This is the deserialized form of the JSON
/// Schema stored in <c>ApiMethod.ParameterDefinitions</c> / <c>ReturnDefinition</c>
/// (and the equivalent TemplateScript / SharedScript columns), the canonical
/// format produced by the Central UI schema builder and the
/// <c>MigrateParametersToJsonSchema</c> migration.
///
/// <para>
/// Unlike the flat <see cref="ParameterDefinition"/> (name → scalar type, no
/// nesting), an <see cref="InboundApiSchema"/> carries the FULL nested type:
/// an <c>object</c> node carries its declared field schemas (and which fields
/// are required); an <c>array</c> node carries its element schema. This lets
/// callers validate complex request/response structures field-by-field and
/// element-by-element to any depth, with path-qualified errors
/// (e.g. <c>order.items[2].quantity</c>).
/// </para>
///
/// <para>
/// The extended type vocabulary (after normalization) is the JSON Schema set:
/// <c>boolean · integer · number · string · object · array</c>. Legacy aliases
/// (<c>bool</c>, <c>int</c>, <c>float</c>, <c>double</c>, <c>list</c>, …) are
/// accepted on parse for transition safety, mirroring the Central UI
/// <c>SchemaBuilderModel</c> / <c>JsonSchemaShapeParser</c> conventions.
/// </para>
/// </summary>
public sealed class InboundApiSchema
{
    /// <summary>Normalized JSON Schema type: one of <c>boolean · integer · number · string · object · array</c>.</summary>
    public string Type { get; init; } = "string";

    /// <summary>For <see cref="Type"/> = <c>object</c>: the declared fields, in declaration order.</summary>
    public IReadOnlyList<InboundApiSchemaField> Fields { get; init; } = [];

    /// <summary>For <see cref="Type"/> = <c>array</c>: the schema every element must satisfy; null means element type was not declared (shape-only).</summary>
    public InboundApiSchema? Items { get; init; }

    /// <summary>Maximum allowed schema nesting depth for both Parse and Validate recursion.</summary>
    private const int MaxDepth = 32;

    // Allow the JSON reader to parse schemas up to ~3× our structural ceiling so
    // the application-level ParseSchema depth guard (MaxDepth = 32) fires before
    // the System.Text.Json reader ceiling. Each structural level contributes
    // roughly 3 JSON-reader nesting levels (object → properties-object → value),
    // so 128 reader levels comfortably accommodates 32+ structural levels.
    private static readonly JsonDocumentOptions DocOptions = new() { MaxDepth = 128 };

    /// <summary>
    /// Parses a stored definition string into an <see cref="InboundApiSchema"/>.
    /// Accepts the canonical JSON Schema object form
    /// (<c>{"type":"object","properties":{…},"required":[…]}</c>) and, for
    /// transition safety, the legacy flat-array parameter form
    /// (<c>[{name,type,required,itemType?}]</c>) which it treats as an object
    /// schema whose properties are the array entries.
    /// </summary>
    /// <param name="json">The definition JSON; null/whitespace yields <c>null</c>.</param>
    /// <returns>The parsed schema, or <c>null</c> when the input is empty.</returns>
    /// <exception cref="JsonException">The input is non-empty but not valid JSON, is a JSON scalar/null at the root, or the schema nesting exceeds <see cref="MaxDepth"/>.</exception>
    public static InboundApiSchema? Parse(string? json)
    {
        if (string.IsNullOrWhiteSpace(json))
        {
            return null;
        }

        using var doc = JsonDocument.Parse(json, DocOptions);
        return doc.RootElement.ValueKind switch
        {
            JsonValueKind.Object => ParseSchema(doc.RootElement, depth: 0),
            JsonValueKind.Array => ParseLegacyArray(doc.RootElement),
            _ => throw new JsonException("Type definition must be a JSON object (JSON Schema) or legacy parameter array."),
        };
    }

    private static InboundApiSchema ParseSchema(JsonElement el, int depth)
    {
        if (depth > MaxDepth)
        {
            throw new JsonException($"Schema nesting exceeds the maximum allowed depth of {MaxDepth}.");
        }

        var type = el.TryGetProperty("type", out var t) && t.ValueKind == JsonValueKind.String
            ? NormalizeType(t.GetString())
            : "string";

        if (type == "array")
        {
            InboundApiSchema? items = null;
            if (el.TryGetProperty("items", out var itemsEl) && itemsEl.ValueKind == JsonValueKind.Object)
            {
                items = ParseSchema(itemsEl, depth + 1);
            }

            return new InboundApiSchema { Type = "array", Items = items };
        }

        if (type == "object")
        {
            var requiredSet = new HashSet<string>(StringComparer.Ordinal);
            if (el.TryGetProperty("required", out var req) && req.ValueKind == JsonValueKind.Array)
            {
                foreach (var r in req.EnumerateArray())
                {
                    if (r.ValueKind == JsonValueKind.String)
                    {
                        var s = r.GetString();
                        if (!string.IsNullOrEmpty(s))
                        {
                            requiredSet.Add(s);
                        }
                    }
                }
            }

            var fields = new List<InboundApiSchemaField>();
            if (el.TryGetProperty("properties", out var props) && props.ValueKind == JsonValueKind.Object)
            {
                foreach (var prop in props.EnumerateObject())
                {
                    var schema = prop.Value.ValueKind == JsonValueKind.Object
                        ? ParseSchema(prop.Value, depth + 1)
                        : new InboundApiSchema { Type = "string" };
                    fields.Add(new InboundApiSchemaField(prop.Name, requiredSet.Contains(prop.Name), schema));
                }
            }

            return new InboundApiSchema { Type = "object", Fields = fields };
        }

        return new InboundApiSchema { Type = type };
    }

    private static InboundApiSchema ParseLegacyArray(JsonElement arr)
    {
        var fields = new List<InboundApiSchemaField>();
        foreach (var item in arr.EnumerateArray())
        {
            if (item.ValueKind != JsonValueKind.Object)
            {
                continue;
            }

            // The legacy flat shape historically appeared with both PascalCase
            // (CLI / anonymous-object serialization read back with
            // PropertyNameCaseInsensitive) and lowercase (DB) keys, so the
            // property lookups here are case-insensitive for compatibility.
            var name = TryGetMember(item, "name", out var n) ? n.GetString() : null;
            if (string.IsNullOrEmpty(name))
            {
                continue;
            }

            var rawType = TryGetMember(item, "type", out var t) ? t.GetString() : "string";

            // A field is optional only when "required" is explicitly false.
            // The SQL migration uses a string comparison (LOWER(...) <> 'false'),
            // so we must also accept the string "false" (case-insensitive) here —
            // not only the JSON boolean false — to stay consistent with legacy rows
            // that stored "required":"false" as a string.
            var required = !TryGetMember(item, "required", out var rq)
                || (rq.ValueKind != JsonValueKind.False
                    && !string.Equals(
                        rq.ValueKind == JsonValueKind.String ? rq.GetString() : null,
                        "false",
                        StringComparison.OrdinalIgnoreCase));

            var normalized = NormalizeType(rawType);
            InboundApiSchema schema;
            if (normalized == "array")
            {
                var inner = TryGetMember(item, "itemType", out var it) ? it.GetString() : null;
                schema = new InboundApiSchema
                {
                    Type = "array",
                    Items = string.IsNullOrEmpty(inner) ? null : new InboundApiSchema { Type = NormalizeType(inner) },
                };
            }
            else
            {
                schema = new InboundApiSchema { Type = normalized };
            }

            fields.Add(new InboundApiSchemaField(name!, required, schema));
        }

        return new InboundApiSchema { Type = "object", Fields = fields };
    }

    /// <summary>
    /// Case-insensitive object-member lookup, used only on the legacy flat-array
    /// path so both PascalCase and lowercase legacy keys resolve.
    /// </summary>
    private static bool TryGetMember(JsonElement obj, string name, out JsonElement value)
    {
        foreach (var prop in obj.EnumerateObject())
        {
            if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase))
            {
                value = prop.Value;
                return true;
            }
        }

        value = default;
        return false;
    }

    /// <summary>
    /// Normalizes a raw type token to the canonical JSON Schema vocabulary,
    /// tolerating legacy aliases. Unknown tokens are returned lowercased so the
    /// validator can surface an explicit "unknown type" error.
    /// </summary>
    /// <param name="raw">The raw type token (may be null).</param>
    /// <returns>The normalized type token.</returns>
    public static string NormalizeType(string? raw) => raw?.ToLowerInvariant() switch
    {
        null or "" => "string",
        "boolean" or "bool" => "boolean",
        "integer" or "int" or "int32" or "int64" => "integer",
        "number" or "float" or "double" or "decimal" => "number",
        // datetime→string is intentional: the legacy migration's SQL
        // normalization function maps "datetime" to "string" (no separate
        // datetime wire type in the extended type system), so C# must match.
        "string" or "datetime" => "string",
        "object" => "object",
        "array" or "list" => "array",
        var other => other,
    };

    /// <summary>
    /// Recursively validates a JSON value against this schema. A JSON <c>null</c>
    /// satisfies any type (a present-but-null field is allowed; absence of a
    /// required field is reported by the parent object). Errors are accumulated
    /// with a path prefix (e.g. <c>order.items[2].quantity</c>) so the caller can
    /// pinpoint the offending field.
    /// </summary>
    /// <param name="value">The JSON value to validate.</param>
    /// <param name="path">The path prefix for the value being validated (empty for the root).</param>
    /// <param name="errors">Accumulator the validator appends path-qualified messages to.</param>
    public void Validate(JsonElement value, string path, List<string> errors)
        => ValidateCore(value, path, errors, depth: 0);

    private void ValidateCore(JsonElement value, string path, List<string> errors, int depth)
    {
        ArgumentNullException.ThrowIfNull(errors);

        if (depth > MaxDepth)
        {
            errors.Add($"{Describe(path)}: schema nesting too deep (max {MaxDepth})");
            return;
        }

        // A null value satisfies any declared type — a present-but-null field is
        // allowed; a MISSING required field is reported by the enclosing object.
        if (value.ValueKind == JsonValueKind.Null)
        {
            return;
        }

        switch (Type)
        {
            case "boolean":
                if (value.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
                {
                    errors.Add(Mismatch(path, "Boolean"));
                }

                break;

            case "integer":
                if (value.ValueKind != JsonValueKind.Number || !value.TryGetInt64(out _))
                {
                    errors.Add(Mismatch(path, "Integer"));
                }

                break;

            case "number":
                if (value.ValueKind != JsonValueKind.Number)
                {
                    errors.Add(Mismatch(path, "Float"));
                }

                break;

            case "string":
                if (value.ValueKind != JsonValueKind.String)
                {
                    errors.Add(Mismatch(path, "String"));
                }

                break;

            case "object":
                ValidateObject(value, path, errors, depth);
                break;

            case "array":
                ValidateArray(value, path, errors, depth);
                break;

            default:
                errors.Add($"{Describe(path)} has unknown declared type '{Type}'");
                break;
        }
    }

    private void ValidateObject(JsonElement value, string path, List<string> errors, int depth)
    {
        if (value.ValueKind != JsonValueKind.Object)
        {
            errors.Add(Mismatch(path, "Object"));
            return;
        }

        // Reject undeclared fields (defensive, consistent with InboundAPI-010's
        // top-level "unexpected parameter" rejection) — a typo'd nested field is
        // surfaced instead of silently ignored. Skipped when no fields are
        // declared (a bare {"type":"object"} stays shape-only, like the legacy
        // behaviour and the array-without-items case).
        if (Fields.Count > 0)
        {
            var declared = new HashSet<string>(Fields.Select(f => f.Name), StringComparer.Ordinal);
            foreach (var prop in value.EnumerateObject())
            {
                if (!declared.Contains(prop.Name))
                {
                    errors.Add($"{Describe(JoinField(path, prop.Name))} is not a declared field");
                }
            }
        }

        foreach (var field in Fields)
        {
            var fieldPath = JoinField(path, field.Name);
            if (value.TryGetProperty(field.Name, out var fieldValue))
            {
                field.Schema.ValidateCore(fieldValue, fieldPath, errors, depth + 1);
            }
            else if (field.Required)
            {
                errors.Add($"missing required field {Describe(fieldPath)}");
            }
        }
    }

    private void ValidateArray(JsonElement value, string path, List<string> errors, int depth)
    {
        if (value.ValueKind != JsonValueKind.Array)
        {
            errors.Add(Mismatch(path, "List"));
            return;
        }

        // No declared element type → shape-only (any elements accepted).
        if (Items is null)
        {
            return;
        }

        var index = 0;
        foreach (var element in value.EnumerateArray())
        {
            Items.ValidateCore(element, $"{path}[{index}]", errors, depth + 1);
            index++;
        }
    }

    private static string Mismatch(string path, string expectedDisplayType) =>
        $"{Describe(path)} must be {Article(expectedDisplayType)} {expectedDisplayType}";

    private static string Describe(string path) =>
        string.IsNullOrEmpty(path) ? "value" : $"'{path}'";

    private static string JoinField(string path, string field) =>
        string.IsNullOrEmpty(path) ? field : $"{path}.{field}";

    private static string Article(string word) =>
        word.Length > 0 && "AEIOU".IndexOf(char.ToUpperInvariant(word[0])) >= 0 ? "an" : "a";
}

/// <summary>
/// One declared field of an <see cref="InboundApiSchema"/> object node: the
/// field name, whether it is required, and its (recursive) type schema.
/// </summary>
/// <param name="Name">The field name as it appears in the JSON.</param>
/// <param name="Required">Whether the field must be present.</param>
/// <param name="Schema">The recursive type schema the field's value must satisfy.</param>
public sealed record InboundApiSchemaField(string Name, bool Required, InboundApiSchema Schema);
