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

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

/// <summary>
/// Tests for the M9-T32b JSON-Schema <c>$ref</c> resolver: the caller-supplied
/// resolution seam (<c>lib:Name</c> pointer convention), cycle/depth guarding,
/// and dangling-ref reporting. The resolver is OPTIONAL — schemas without a
/// <c>$ref</c> parse exactly as before (verified by the no-ref-unchanged tests).
/// </summary>
public class InboundApiSchemaRefTests
{
    // ── Pointer convention: {"$ref":"lib:Name"} resolves through the seam ──────

    [Fact]
    public void ParseWithRefs_LibRef_ResolvesToReferencedShape()
    {
        // "Foo" is an object schema with a single required integer field.
        const string fooSchema = """{"type":"object","properties":{"qty":{"type":"integer"}},"required":["qty"]}""";
        const string json = """{"$ref":"lib:Foo"}""";

        var result = InboundApiSchema.ParseWithRefs(
            json,
            name => name == "Foo" ? fooSchema : null);

        Assert.Empty(result.UnresolvedRefs);
        Assert.NotNull(result.Schema);
        Assert.Equal("object", result.Schema!.Type);
        var field = Assert.Single(result.Schema.Fields);
        Assert.Equal("qty", field.Name);
        Assert.True(field.Required);
        Assert.Equal("integer", field.Schema.Type);
    }

    [Fact]
    public void ParseWithRefs_NestedFieldRef_ResolvesInline()
    {
        // A $ref nested inside a property is resolved to the referenced schema.
        const string addressSchema = """{"type":"object","properties":{"zip":{"type":"string"}}}""";
        const string json = """{"type":"object","properties":{"shipTo":{"$ref":"lib:Address"}}}""";

        var result = InboundApiSchema.ParseWithRefs(
            json,
            name => name == "Address" ? addressSchema : null);

        Assert.Empty(result.UnresolvedRefs);
        Assert.NotNull(result.Schema);
        var shipTo = Assert.Single(result.Schema!.Fields);
        Assert.Equal("shipTo", shipTo.Name);
        Assert.Equal("object", shipTo.Schema.Type);
        Assert.Equal("zip", Assert.Single(shipTo.Schema.Fields).Name);
    }

    [Fact]
    public void Parse_LibRef_WithResolver_ResolvesToReferencedShape()
    {
        // The throwing Parse overload also resolves a valid ref.
        const string fooSchema = """{"type":"object","properties":{"name":{"type":"string"}}}""";
        var schema = InboundApiSchema.Parse(
            """{"$ref":"lib:Foo"}""",
            name => name == "Foo" ? fooSchema : null);

        Assert.NotNull(schema);
        Assert.Equal("object", schema!.Type);
        Assert.Equal("name", Assert.Single(schema.Fields).Name);
    }

    // ── Dangling ref: resolver returns null → reported, not silently dropped ──

    [Fact]
    public void ParseWithRefs_DanglingRef_IsReported()
    {
        const string json = """{"$ref":"lib:Missing"}""";

        var result = InboundApiSchema.ParseWithRefs(json, _ => null);

        Assert.Contains("Missing", result.UnresolvedRefs);
    }

    [Fact]
    public void ParseWithRefs_DanglingNestedRef_IsReported()
    {
        const string json = """{"type":"object","properties":{"a":{"$ref":"lib:Gone"}}}""";

        var result = InboundApiSchema.ParseWithRefs(json, _ => null);

        Assert.Contains("Gone", result.UnresolvedRefs);
    }

    [Fact]
    public void ParseWithRefs_RefWithNoResolver_IsDangling()
    {
        // When no resolver is supplied, a $ref cannot be resolved → dangling
        // (the safe option: surfaced, never silently treated as a valid shape).
        const string json = """{"$ref":"lib:Whatever"}""";

        var result = InboundApiSchema.ParseWithRefs(json, resolveRef: null);

        Assert.Contains("Whatever", result.UnresolvedRefs);
    }

    [Fact]
    public void Parse_DanglingRef_WithResolver_Throws()
    {
        // The throwing Parse overload surfaces a dangling ref as a JsonException
        // (consistent with its existing throw-on-structural-error contract).
        Assert.Throws<JsonException>(() =>
            InboundApiSchema.Parse("""{"$ref":"lib:Nope"}""", _ => null));
    }

    // ── Cycle / depth guard: controlled error, no stack overflow ──────────────

    [Fact]
    public void ParseWithRefs_DirectCycle_IsReportedNotStackOverflow()
    {
        // Foo -> Foo. Must terminate with a controlled cycle error.
        var schemas = new Dictionary<string, string>
        {
            ["Foo"] = """{"$ref":"lib:Foo"}""",
        };

        var result = InboundApiSchema.ParseWithRefs(
            """{"$ref":"lib:Foo"}""",
            name => schemas.GetValueOrDefault(name));

        Assert.NotEmpty(result.UnresolvedRefs);
        Assert.Contains(result.UnresolvedRefs, r => r.Contains("Foo", StringComparison.Ordinal));
    }

    [Fact]
    public void ParseWithRefs_IndirectCycle_IsReportedNotStackOverflow()
    {
        // Foo -> Bar -> Foo. Must terminate with a controlled cycle error.
        var schemas = new Dictionary<string, string>
        {
            ["Foo"] = """{"$ref":"lib:Bar"}""",
            ["Bar"] = """{"$ref":"lib:Foo"}""",
        };

        var result = InboundApiSchema.ParseWithRefs(
            """{"$ref":"lib:Foo"}""",
            name => schemas.GetValueOrDefault(name));

        Assert.NotEmpty(result.UnresolvedRefs);
    }

    [Fact]
    public void Parse_DirectCycle_WithResolver_ThrowsNotStackOverflow()
    {
        var schemas = new Dictionary<string, string>
        {
            ["Foo"] = """{"$ref":"lib:Foo"}""",
        };

        Assert.Throws<JsonException>(() =>
            InboundApiSchema.Parse(
                """{"$ref":"lib:Foo"}""",
                name => schemas.GetValueOrDefault(name)));
    }

    [Fact]
    public void ParseWithRefs_DeepRefChain_RespectsDepthCapWithoutOverflow()
    {
        // Build a long non-cyclic chain that exceeds the structural depth cap.
        // The guard must terminate with a controlled error, never overflow.
        var schemas = new Dictionary<string, string>();
        for (var i = 0; i < 200; i++)
        {
            schemas[$"S{i}"] = $$"""{"$ref":"lib:S{{i + 1}}"}""";
        }
        // The final ref dangles, but the depth guard should fire well before that.
        var result = InboundApiSchema.ParseWithRefs(
            """{"$ref":"lib:S0"}""",
            name => schemas.GetValueOrDefault(name));

        Assert.NotEmpty(result.UnresolvedRefs);
    }

    // ── Behavior unchanged for schemas WITHOUT a $ref ─────────────────────────

    [Fact]
    public void ParseWithRefs_NoRef_MatchesParse()
    {
        const string json = """{"type":"object","properties":{"a":{"type":"integer"},"b":{"type":"string"}},"required":["a"]}""";

        var legacy = InboundApiSchema.Parse(json);
        var withRefs = InboundApiSchema.ParseWithRefs(json, _ => null);

        Assert.Empty(withRefs.UnresolvedRefs);
        Assert.NotNull(withRefs.Schema);
        Assert.Equal(legacy!.Type, withRefs.Schema!.Type);
        Assert.Equal(legacy.Fields.Count, withRefs.Schema.Fields.Count);
        Assert.Equal("a", withRefs.Schema.Fields[0].Name);
        Assert.True(withRefs.Schema.Fields[0].Required);
        Assert.False(withRefs.Schema.Fields[1].Required);
    }

    [Fact]
    public void Parse_NoRef_UnchangedWithOrWithoutResolver()
    {
        const string json = """{"type":"array","items":{"type":"number"}}""";

        var noResolver = InboundApiSchema.Parse(json);
        var withResolver = InboundApiSchema.Parse(json, _ => "unused");

        Assert.NotNull(noResolver);
        Assert.NotNull(withResolver);
        Assert.Equal("array", noResolver!.Type);
        Assert.Equal("array", withResolver!.Type);
        Assert.Equal("number", noResolver.Items!.Type);
        Assert.Equal("number", withResolver.Items!.Type);
    }

    [Fact]
    public void ParseWithRefs_Empty_ReturnsNullSchemaNoRefs()
    {
        var result = InboundApiSchema.ParseWithRefs(null, _ => "x");
        Assert.Null(result.Schema);
        Assert.Empty(result.UnresolvedRefs);
    }

    // ── Diamond: two sibling refs to the SAME name resolve (not a cycle) ───────

    [Fact]
    public void ParseWithRefs_DiamondSiblingRefs_ResolveBothNotFalseCycle()
    {
        // Two sibling properties BOTH reference {"$ref":"lib:Shared"}. The shared
        // name is popped from the cycle guard after each subtree is resolved, so the
        // SECOND sibling must resolve too — a diamond is NOT a cycle and must not
        // trip the guard. Regression for the finally-pop semantics in ResolveLibRef.
        const string shared = """{"type":"object","properties":{"v":{"type":"integer"}},"required":["v"]}""";
        const string json =
            """{"type":"object","properties":{"left":{"$ref":"lib:Shared"},"right":{"$ref":"lib:Shared"}}}""";

        var result = InboundApiSchema.ParseWithRefs(
            json,
            name => name == "Shared" ? shared : null);

        // Neither sibling should be reported unresolved — both resolved cleanly.
        Assert.Empty(result.UnresolvedRefs);
        Assert.NotNull(result.Schema);
        Assert.Equal(2, result.Schema!.Fields.Count);

        foreach (var sibling in new[] { "left", "right" })
        {
            var field = Assert.Single(result.Schema.Fields, f => f.Name == sibling);
            Assert.Equal("object", field.Schema.Type);
            var inner = Assert.Single(field.Schema.Fields);
            Assert.Equal("v", inner.Name);
            Assert.True(inner.Required);
            Assert.Equal("integer", inner.Schema.Type);
        }
    }
}
