using Bunit;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;

namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design;

/// <summary>
/// M9-T30: <c>ParameterValueForm</c> renders typed nested inputs for object and
/// list parameters (replacing the raw-JSON textarea fallback), driven by the
/// declared schema (with <c>{"$ref":"lib:Name"}</c> resolved via
/// <see cref="ISchemaLibraryQueryService"/>), with per-field validation, and
/// emits the SAME canonical JSON value shape the form has always produced
/// (scalars as bool/long/double/string, objects as
/// <c>Dictionary&lt;string,object?&gt;</c>, lists as <c>List&lt;object?&gt;</c>).
/// </summary>
public class ParameterValueFormTests : BunitContext
{
    private readonly ISchemaLibraryQueryService _library = Substitute.For<ISchemaLibraryQueryService>();

    public ParameterValueFormTests()
    {
        // Default: empty library (no $ref entries). Individual tests override.
        _library.GetSchemaMapAsync(Arg.Any<CancellationToken>())
            .Returns(new Dictionary<string, string>());
        Services.AddSingleton(_library);
    }

    private const string ObjectParamSchema = """
        {
          "type": "object",
          "properties": {
            "order": {
              "type": "object",
              "properties": {
                "id": { "type": "integer" },
                "name": { "type": "string" }
              },
              "required": ["id"]
            }
          },
          "required": ["order"]
        }
        """;

    private const string ListParamSchema = """
        {
          "type": "object",
          "properties": {
            "tags": {
              "type": "array",
              "items": { "type": "string" }
            }
          }
        }
        """;

    [Fact]
    public void ObjectParameter_RendersPerFieldInputs_NotRawTextarea()
    {
        var cut = Render<ParameterValueForm>(p => p
            .Add(x => x.ParameterDefinitions, ObjectParamSchema)
            .Add(x => x.Values, new Dictionary<string, object?>()));

        // No raw-JSON textarea fallback for the object parameter.
        Assert.Empty(cut.FindAll("textarea"));

        // Nested fields are rendered as their own typed inputs: an Integer (number)
        // for order.id and a String (text) for order.name.
        var numberInputs = cut.FindAll("input[type=number]");
        var textInputs = cut.FindAll("input[type=text]");
        Assert.NotEmpty(numberInputs);
        Assert.NotEmpty(textInputs);

        // The nested field labels surface.
        Assert.Contains("id", cut.Markup);
        Assert.Contains("name", cut.Markup);
    }

    [Fact]
    public void ObjectParameter_RoundTripsToCanonicalNestedJsonShape()
    {
        Dictionary<string, object?>? emitted = null;
        var cut = Render<ParameterValueForm>(p => p
            .Add(x => x.ParameterDefinitions, ObjectParamSchema)
            .Add(x => x.Values, new Dictionary<string, object?>())
            .Add(x => x.ValuesChanged, v => emitted = v));

        // Enter the nested integer field (order.id).
        var idInput = cut.FindAll("input[type=number]")[0];
        idInput.Input("42");

        // Enter the nested string field (order.name).
        var nameInput = cut.FindAll("input[type=text]")[0];
        nameInput.Input("widget");

        Assert.NotNull(emitted);
        var order = Assert.IsType<Dictionary<string, object?>>(emitted!["order"]);
        Assert.Equal(42L, order["id"]);
        Assert.Equal("widget", order["name"]);
    }

    [Fact]
    public void ListParameter_RendersAddRemoveTypedItemRows()
    {
        Dictionary<string, object?>? emitted = null;
        var cut = Render<ParameterValueForm>(p => p
            .Add(x => x.ParameterDefinitions, ListParamSchema)
            .Add(x => x.Values, new Dictionary<string, object?>())
            .Add(x => x.ValuesChanged, v => emitted = v));

        // No raw-JSON textarea fallback for the list parameter.
        Assert.Empty(cut.FindAll("textarea"));

        // An "add item" button exists; clicking it appends a typed item editor.
        var addButton = cut.Find("button.param-list-add");
        addButton.Click();

        var itemInput = cut.Find("input.param-list-item");
        itemInput.Input("alpha");

        Assert.NotNull(emitted);
        var tags = Assert.IsType<List<object?>>(emitted!["tags"]);
        Assert.Single(tags);
        Assert.Equal("alpha", tags[0]);

        // Removing the row drops it back to an empty list.
        cut.Find("button.param-list-remove").Click();
        var tagsAfter = Assert.IsType<List<object?>>(emitted!["tags"]);
        Assert.Empty(tagsAfter);
    }

    [Fact]
    public void RefBearingParameter_RendersResolvedShape()
    {
        // The library entry "Address" resolves to an object schema with two string
        // fields; the parameter references it via {"$ref":"lib:Address"}.
        const string libSchema = """
            {
              "type": "object",
              "properties": {
                "street": { "type": "string" },
                "city": { "type": "string" }
              }
            }
            """;
        _library.GetSchemaMapAsync(Arg.Any<CancellationToken>())
            .Returns(new Dictionary<string, string> { ["Address"] = libSchema });

        const string paramSchema = """
            {
              "type": "object",
              "properties": {
                "shipTo": { "$ref": "lib:Address" }
              }
            }
            """;

        var cut = Render<ParameterValueForm>(p => p
            .Add(x => x.ParameterDefinitions, paramSchema)
            .Add(x => x.Values, new Dictionary<string, object?>()));

        // The resolved shape renders its fields (no raw textarea, no unresolved-ref message).
        Assert.Empty(cut.FindAll("textarea"));
        Assert.Contains("street", cut.Markup);
        Assert.Contains("city", cut.Markup);
        // Two string inputs for the resolved object's two fields.
        Assert.Equal(2, cut.FindAll("input[type=text]").Count);
    }

    [Fact]
    public void PerFieldValidationError_SurfacesInline()
    {
        // A required nested field (order.id is an integer) left non-numeric must
        // surface a per-field error inline.
        var cut = Render<ParameterValueForm>(p => p
            .Add(x => x.ParameterDefinitions, ObjectParamSchema)
            .Add(x => x.Values, new Dictionary<string, object?>()));

        var idInput = cut.FindAll("input[type=number]")[0];
        idInput.Input("not-a-number");

        // A field-level error class is shown.
        Assert.NotEmpty(cut.FindAll(".text-danger"));
    }

    // M1 — top-level $ref: empty-state guard must key off resolved fields, not _shapes
    [Fact]
    public void TopLevelRef_ResolvesToObject_RendersFields_NotEmptyState()
    {
        // Library entry "SomeObject" resolves to an object with two fields.
        const string libSchema = """
            {
              "type": "object",
              "properties": {
                "code": { "type": "integer" },
                "label": { "type": "string" }
              },
              "required": ["code"]
            }
            """;
        _library.GetSchemaMapAsync(Arg.Any<CancellationToken>())
            .Returns(new Dictionary<string, string> { ["SomeObject"] = libSchema });

        // The parameter definitions IS a top-level $ref — no "properties" key at the
        // root level, so _shapes (JsonSchemaShapeParser) stays empty.
        const string paramSchema = """{"$ref":"lib:SomeObject"}""";

        var cut = Render<ParameterValueForm>(p => p
            .Add(x => x.ParameterDefinitions, paramSchema)
            .Add(x => x.Values, new Dictionary<string, object?>()));

        // Must NOT show the empty-state message.
        Assert.DoesNotContain("No parameters declared", cut.Markup);

        // Must render the resolved object's two fields.
        Assert.Contains("code", cut.Markup);
        Assert.Contains("label", cut.Markup);
        // Integer and String inputs are present.
        Assert.NotEmpty(cut.FindAll("input[type=number]"));
        Assert.NotEmpty(cut.FindAll("input[type=text]"));
    }

    // I1 — list item removal: in-progress raw text of later items must SHIFT, not wipe.
    // Uses a list-of-objects schema so sub-field raw-text entries (e.g. "rows[1].qty")
    // persist in the rendered list (the LIST SLOT stays; only the nested field value
    // is cleared on invalid input, not the containing dict). This is the scenario the
    // ShiftDict fix is designed for: "rows[1].qty" → "rows[0].qty" when rows[0] is
    // removed, so the error/raw-text follows the item rather than being wiped.
    [Fact]
    public void ListItemRemoval_ShiftsNestedRawTextOfLaterItems_NotWiped()
    {
        // List of objects, each with an integer "qty" field.
        const string schema = """
            {
              "type": "object",
              "properties": {
                "rows": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "properties": {
                      "qty": { "type": "integer" }
                    }
                  }
                }
              }
            }
            """;

        var cut = Render<ParameterValueForm>(p => p
            .Add(x => x.ParameterDefinitions, schema)
            .Add(x => x.Values, new Dictionary<string, object?>())
            .Add(x => x.ValuesChanged, _ => { }));

        // Add two object items.
        var addButton = cut.Find("button.param-list-add");
        addButton.Click();  // rows[0]
        addButton.Click();  // rows[1]

        // Commit a valid qty in rows[0].
        var numberInputs = cut.FindAll("input[type=number]");
        numberInputs[0].Input("5");

        // Enter an INVALID qty in rows[1] — parks raw text "abc" in _rawText["rows[1].qty"]
        // and _parseErrors["rows[1].qty"]. The LIST SLOT rows[1] is preserved (only the
        // nested field value is cleared, not the containing dict).
        numberInputs = cut.FindAll("input[type=number]");
        numberInputs[1].Input("abc");

        // The error for rows[1].qty is visible before removal.
        Assert.NotEmpty(cut.FindAll(".text-danger"));

        // Remove rows[0]. With the fix, "rows[1].qty" raw-text/error entries shift to
        // "rows[0].qty", following the item. Without the fix they are wiped and the
        // item renders as if freshly added (no error).
        cut.Find("button.param-list-remove").Click();

        // After removal, the remaining item (formerly rows[1]) must still show the
        // invalid-field error. Two remove buttons collapsed to one; one item renders.
        Assert.Single(cut.FindAll("button.param-list-remove"));
        Assert.NotEmpty(cut.FindAll(".text-danger"));
    }
}
