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) => Parse(json, resolveRef: null);

    /// <summary>
    /// Parses a stored definition string into an <see cref="InboundApiSchema"/>,
    /// resolving any <c>{"$ref":"lib:Name"}</c> library references through
    /// the caller-supplied <paramref name="resolveRef"/> seam.
    ///
    /// <para>
    /// The pointer convention is <c>lib:Name</c>: a JSON object carrying a string
    /// <c>$ref</c> whose value begins with the <c>lib:</c> scheme prefix is a
    /// reference to the library entry named by the remainder. The seam maps that
    /// name (the part after <c>lib:</c>) to the referenced schema JSON, or returns
    /// <c>null</c> when the entry does not exist. Resolution is recursive (a
    /// referenced schema may itself contain <c>$ref</c>s) and guarded against
    /// cycles and excessive depth — a cycle or over-depth chain surfaces as a
    /// controlled <see cref="JsonException"/>, never a stack overflow.
    /// </para>
    ///
    /// <para>
    /// A <c>$ref</c> the seam cannot resolve (returns <c>null</c>), a <c>$ref</c>
    /// with no resolver supplied, and a cyclic/over-depth <c>$ref</c> are all
    /// DANGLING — surfaced here as a <see cref="JsonException"/> so a caller using
    /// the throwing path treats them as a hard error. (For deploy-time validation
    /// that needs to COLLECT dangling refs rather than throw, use
    /// <see cref="ParseWithRefs"/>.)
    /// </para>
    ///
    /// <para>
    /// Schemas with no <c>$ref</c> parse identically to <see cref="Parse(string?)"/>;
    /// the resolver is never consulted for them, so behavior is unchanged.
    /// </para>
    /// </summary>
    /// <param name="json">The definition JSON; null/whitespace yields <c>null</c>.</param>
    /// <param name="resolveRef">
    /// Optional reference-resolution seam mapping a <c>lib:Name</c> target's name to
    /// the referenced schema JSON (or <c>null</c> when not found). <c>null</c> means
    /// no resolver: a <c>$ref</c> then dangles. The seam keeps this Commons type free
    /// of any repository dependency — the caller (the validation layer) supplies it.
    /// </param>
    /// <returns>The parsed (and ref-resolved) schema, or <c>null</c> when the input is empty.</returns>
    /// <exception cref="JsonException">The input is invalid JSON, a root scalar/null, exceeds <see cref="MaxDepth"/>, or contains a dangling/cyclic/over-depth <c>$ref</c>.</exception>
    public static InboundApiSchema? Parse(string? json, Func<string, string?>? resolveRef)
    {
        var result = ParseWithRefs(json, resolveRef);
        if (result.UnresolvedReferences.Count > 0)
        {
            throw new JsonException(
                $"Schema contains unresolved $ref(s): {string.Join(", ", result.UnresolvedReferences.Select(r => r.Describe()))}.");
        }

        return result.Schema;
    }

    /// <summary>
    /// Parses a stored definition string into an <see cref="InboundApiSchema"/>,
    /// resolving <c>{"$ref":"lib:Name"}</c> library references through the
    /// caller-supplied <paramref name="resolveRef"/> seam, and COLLECTING (rather than
    /// throwing on) any references that cannot be resolved.
    ///
    /// <para>
    /// This is the deploy-time entry point: a dangling, cyclic, or over-depth
    /// <c>$ref</c> is reported in <see cref="SchemaParseResult.UnresolvedRefs"/> so the
    /// validation layer can surface it as a deploy-blocking error naming the missing
    /// reference, instead of aborting the whole parse. See <see cref="Parse(string?, Func{string, string?})"/>
    /// for the throwing variant and for the <c>lib:Name</c> pointer convention.
    /// </para>
    /// </summary>
    /// <param name="json">The definition JSON; null/whitespace yields a result with a <c>null</c> schema and no unresolved refs.</param>
    /// <param name="resolveRef">
    /// Optional reference-resolution seam (see <see cref="Parse(string?, Func{string, string?})"/>).
    /// <c>null</c> means no resolver: any <c>$ref</c> is reported as unresolved.
    /// </param>
    /// <returns>The parsed schema (refs resolved where possible) plus the names of any references that could not be resolved.</returns>
    /// <exception cref="JsonException">The input is invalid JSON, a root scalar/null, or exceeds the structural <see cref="MaxDepth"/> for non-ref nesting.</exception>
    public static SchemaParseResult ParseWithRefs(string? json, Func<string, string?>? resolveRef)
    {
        if (string.IsNullOrWhiteSpace(json))
        {
            return new SchemaParseResult(null, []);
        }

        var unresolved = new List<UnresolvedSchemaRef>();
        // The active-ref set tracks the refs being resolved on the CURRENT path so a
        // cycle (A→B→A) is detected and reported instead of recursing forever.
        var ctx = new RefResolutionContext(resolveRef, unresolved, new HashSet<string>(StringComparer.Ordinal));

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

        return new SchemaParseResult(schema, unresolved);
    }

    /// <summary>
    /// Carries the <c>$ref</c> resolution state threaded through the recursive parse:
    /// the caller-supplied resolver seam, the accumulator for unresolved references,
    /// and the set of references active on the current resolution path (the cycle
    /// guard). A <c>null</c> <see cref="Resolver"/> means no resolver was supplied —
    /// every <c>$ref</c> then dangles.
    /// </summary>
    private sealed record RefResolutionContext(
        Func<string, string?>? Resolver,
        List<UnresolvedSchemaRef> Unresolved,
        HashSet<string> ActiveRefs);

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

        // $ref resolution: a {"$ref":"lib:Name"} node is replaced by the
        // referenced schema, resolved through the caller-supplied seam. Dangling,
        // cyclic, and over-depth refs are recorded as unresolved (the caller decides
        // whether to throw or collect) and parse continues with a shape-only schema.
        if (TryReadLibRef(el, out var refName))
        {
            return ResolveLibRef(refName, depth, ctx);
        }

        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, ctx);
            }

            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, ctx)
                        : 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 };
    }

    /// <summary>The <c>lib:</c> scheme prefix on a <c>$ref</c> value identifying a library reference.</summary>
    private const string LibRefScheme = "lib:";

    /// <summary>The placeholder type for an unresolvable <c>$ref</c> node (dangling, cyclic, or over-depth).</summary>
    private const string UnresolvedRefType = "ref";

    /// <summary>
    /// Cheap pre-flight check: does this definition JSON contain ANY
    /// <c>$ref</c> token at all? Lets a caller (e.g. the InboundAPI runtime path) skip the
    /// shared-schema library pre-load entirely when a schema uses no references — so a
    /// <c>$ref</c>-free method pays NO extra cost beyond today. The check is intentionally
    /// conservative (a substring scan, not a parse): it may return <c>true</c> for a
    /// schema whose only <c>$ref</c> is not a <c>lib:</c> reference, in which case the
    /// subsequent <see cref="ParseWithRefs"/> simply never consults the resolver — correct,
    /// just not maximally lazy. It never returns <c>false</c> when a real <c>lib:</c> ref
    /// is present, so it is always safe to gate the pre-load on.
    /// </summary>
    /// <param name="json">The definition JSON to scan; <c>null</c>/empty yields <c>false</c>.</param>
    /// <returns><c>true</c> when the JSON contains a <c>$ref</c> token and a resolver may be needed.</returns>
    public static bool MightContainRef(string? json) =>
        !string.IsNullOrEmpty(json) && json.Contains("$ref", StringComparison.Ordinal);

    /// <summary>
    /// Recognizes a <c>{"$ref":"lib:Name"}</c> reference node and extracts its target
    /// name (the part after the <c>lib:</c> scheme prefix). Returns <c>false</c> for any
    /// node that is not a <c>lib:</c> reference, so non-ref schemas take the normal path.
    /// </summary>
    /// <param name="el">The schema node to inspect.</param>
    /// <param name="refName">The resolved target name (after <c>lib:</c>) when this is a <c>lib:</c> ref; otherwise empty.</param>
    /// <returns><c>true</c> when <paramref name="el"/> is a <c>lib:</c> <c>$ref</c> node with a non-empty target name.</returns>
    private static bool TryReadLibRef(JsonElement el, out string refName)
    {
        refName = string.Empty;
        if (!el.TryGetProperty("$ref", out var refEl) || refEl.ValueKind != JsonValueKind.String)
        {
            return false;
        }

        var raw = refEl.GetString();
        if (string.IsNullOrEmpty(raw) || !raw.StartsWith(LibRefScheme, StringComparison.Ordinal))
        {
            return false;
        }

        var name = raw[LibRefScheme.Length..].Trim();
        if (name.Length == 0)
        {
            return false;
        }

        refName = name;
        return true;
    }

    /// <summary>
    /// Resolves a <c>lib:Name</c> reference through the seam, parsing the referenced
    /// schema (which may itself contain <c>$ref</c>s). Dangling (seam returns null or no
    /// seam), cyclic (the name is already active on the current path), and over-depth
    /// references are recorded in the context's unresolved list and yield a shape-only
    /// placeholder schema so the parse terminates without throwing or overflowing.
    /// </summary>
    /// <param name="refName">The library entry name to resolve.</param>
    /// <param name="depth">The current structural depth (shared with the <see cref="MaxDepth"/> guard).</param>
    /// <param name="ctx">The active resolution context (seam, unresolved accumulator, cycle guard).</param>
    /// <returns>The resolved schema, or a placeholder <c>ref</c>-typed schema when unresolvable.</returns>
    private static InboundApiSchema ResolveLibRef(string refName, int depth, RefResolutionContext ctx)
    {
        // Depth guard: a long (even non-cyclic) ref chain is bounded by the same
        // structural ceiling as nested objects/arrays — terminate, never overflow.
        // The guard fires at `>= MaxDepth` (one level BEFORE ParseSchema's own
        // `> MaxDepth` throw) so an over-depth ref is COLLECTED as unresolved on the
        // ParseWithRefs path rather than aborting the whole parse with a throw.
        if (depth >= MaxDepth)
        {
            ctx.Unresolved.Add(new UnresolvedSchemaRef(refName, $"ref nesting exceeds depth {MaxDepth}"));
            return new InboundApiSchema { Type = UnresolvedRefType };
        }

        // Cycle guard: this name is already being resolved on the current path.
        if (ctx.ActiveRefs.Contains(refName))
        {
            ctx.Unresolved.Add(new UnresolvedSchemaRef(refName, "cyclic reference"));
            return new InboundApiSchema { Type = UnresolvedRefType };
        }

        var referenced = ctx.Resolver?.Invoke(refName);
        if (string.IsNullOrWhiteSpace(referenced))
        {
            // Dangling: the seam can't resolve it (or no seam was supplied).
            ctx.Unresolved.Add(new UnresolvedSchemaRef(refName, Reason: null));
            return new InboundApiSchema { Type = UnresolvedRefType };
        }

        ctx.ActiveRefs.Add(refName);
        try
        {
            using var doc = JsonDocument.Parse(referenced, DocOptions);
            return doc.RootElement.ValueKind switch
            {
                JsonValueKind.Object => ParseSchema(doc.RootElement, depth + 1, ctx),
                JsonValueKind.Array => ParseLegacyArray(doc.RootElement),
                _ => throw new JsonException(
                    $"Referenced schema 'lib:{refName}' must be a JSON object (JSON Schema) or legacy parameter array."),
            };
        }
        finally
        {
            // Pop only AFTER the subtree is resolved so sibling refs to the same name
            // are allowed (a diamond is not a cycle) while a self-revisit on the path is caught.
            ctx.ActiveRefs.Remove(refName);
        }
    }

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

/// <summary>
/// A single <c>{"$ref":"lib:Name"}</c> reference that could NOT be resolved during
/// <see cref="InboundApiSchema.ParseWithRefs"/>. The reference <see cref="Name"/>
/// is kept SEPARATE from the diagnostic <see cref="Reason"/> so a message can render the
/// pointer cleanly (e.g. <c>schema 'lib:Foo' could not be resolved (cyclic reference)</c>)
/// rather than embedding the annotation inside the <c>lib:</c>-looking string.
/// </summary>
/// <param name="Name">
/// The library entry name (the part after the <c>lib:</c> scheme prefix) that could not be
/// resolved — never carries an annotation.
/// </param>
/// <param name="Reason">
/// The diagnostic reason for cyclic/over-depth cases (e.g. <c>"cyclic reference"</c> or
/// <c>"ref nesting exceeds depth 64"</c>), or <c>null</c> for a plain dangling reference
/// (the seam returned <c>null</c>, or no seam was supplied).
/// </param>
public sealed record UnresolvedSchemaRef(string Name, string? Reason)
{
    /// <summary>
    /// Renders this reference as the legacy single-string form (<c>Name</c> for a plain
    /// dangling ref, <c>"Name (Reason)"</c> when annotated) — used to project the
    /// backward-compatible <see cref="SchemaParseResult.UnresolvedRefs"/> list.
    /// </summary>
    /// <returns>The reference name, with the reason appended in parentheses when present.</returns>
    public string ToLegacyString() => Reason is null ? Name : $"{Name} ({Reason})";

    /// <summary>
    /// Renders this reference for an end-user error message, keeping the <c>lib:</c>-qualified
    /// pointer name separate from the parenthesised reason
    /// (e.g. <c>lib:Foo (cyclic reference)</c> or just <c>lib:Foo</c>).
    /// </summary>
    /// <returns>The <c>lib:</c>-qualified reference, with the reason appended in parentheses when present.</returns>
    public string Describe() => Reason is null ? $"lib:{Name}" : $"lib:{Name} ({Reason})";
}

/// <summary>
/// The outcome of <see cref="InboundApiSchema.ParseWithRefs"/>: the parsed
/// schema (with <c>{"$ref":"lib:Name"}</c> references resolved where possible) plus the
/// references that could NOT be resolved — dangling (the seam returned <c>null</c> or no
/// seam was supplied), cyclic, or over-depth. A non-empty <see cref="UnresolvedReferences"/>
/// is the deploy-/runtime-blocking signal the validation layer acts on.
/// </summary>
/// <param name="Schema">The parsed schema, or <c>null</c> when the input was empty.</param>
/// <param name="UnresolvedReferences">
/// The structured reference targets that could not be resolved — each carrying the bare
/// <see cref="UnresolvedSchemaRef.Name"/> separate from an optional
/// <see cref="UnresolvedSchemaRef.Reason"/>. Empty when every reference resolved.
/// </param>
public sealed record SchemaParseResult(
    InboundApiSchema? Schema,
    IReadOnlyList<UnresolvedSchemaRef> UnresolvedReferences)
{
    /// <summary>
    /// Backward-compatible flat view of <see cref="UnresolvedReferences"/>: each entry is the
    /// reference name, with the reason appended in parentheses for cyclic/over-depth cases
    /// (e.g. <c>"Foo (cyclic reference)"</c>). Empty when every reference resolved. Prefer
    /// <see cref="UnresolvedReferences"/> for new code that needs the name and reason apart.
    /// </summary>
    public IReadOnlyList<string> UnresolvedRefs =>
        UnresolvedReferences.Select(r => r.ToLegacyString()).ToList();
}
