using System.Text.Json.Nodes;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;

namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;

// Typed AdminUI editor model for the Mqtt driver — Plain (topic-bound) and Sparkplug B (birth/alias
// ingest) modes. The model is a thin typed view over a preserved JsonObject key bag: every key the
// editor does not expose (history intent, array config, alarm objects) must survive a load→save
// untouched.
//
// The authoritative consumers of the produced blob are MqttTagDefinitionFactory.FromTagConfig (Plain)
// and .FromSparkplugTagConfig (Sparkplug B) (Driver.Mqtt.Contracts) — the key names + strictness rules
// asserted here are those factories', not this editor's invention. TagConfig carries NO identity under
// v3 (the tag's RawPath is its identity), so there is deliberately no FullName key — in EITHER mode.
public sealed class MqttTagConfigModelTests
{
    [Theory]
    [InlineData(null)]
    [InlineData("")]
    [InlineData("   ")]
    [InlineData("{}")]
    [InlineData("not json at all")]
    public void FromJson_returns_defaults_for_empty_input(string? json)
    {
        var m = MqttTagConfigModel.FromJson(json);

        m.Mode.ShouldBe(MqttMode.Plain);
        m.Topic.ShouldBe("");
        m.PayloadFormat.ShouldBe(MqttPayloadFormat.Json);
        // An UNAUTHORED tag seeds the document-root path so the common "no extraction needed" case is
        // visible and one click away — it is a guide, not a requirement (blank is accepted, see below).
        m.JsonPath.ShouldBe("$");
        m.DataType.ShouldBe(DriverDataType.String);
        m.Qos.ShouldBeNull();
        m.RetainSeed.ShouldBeNull();
    }

    [Fact]
    public void FromJson_reads_every_plain_field()
    {
        var m = MqttTagConfigModel.FromJson(
            """{"topic":"factory/oven/temp","payloadFormat":"Json","jsonPath":"$.value","dataType":"Float64","qos":1,"retainSeed":false}""");

        m.Mode.ShouldBe(MqttMode.Plain);
        m.Topic.ShouldBe("factory/oven/temp");
        m.PayloadFormat.ShouldBe(MqttPayloadFormat.Json);
        m.JsonPath.ShouldBe("$.value");
        m.DataType.ShouldBe(DriverDataType.Float64);
        m.Qos.ShouldBe(1);
        m.RetainSeed.ShouldBe(false);
    }

    [Fact]
    public void Round_trip_preserves_every_plain_field()
    {
        var m = new MqttTagConfigModel
        {
            Topic = "line3/press",
            PayloadFormat = MqttPayloadFormat.Scalar,
            JsonPath = "$.a.b",
            DataType = DriverDataType.Int32,
            Qos = 2,
            RetainSeed = true,
        };

        var m2 = MqttTagConfigModel.FromJson(m.ToJson());

        m2.Topic.ShouldBe("line3/press");
        m2.PayloadFormat.ShouldBe(MqttPayloadFormat.Scalar);
        m2.JsonPath.ShouldBe("$.a.b");
        m2.DataType.ShouldBe(DriverDataType.Int32);
        m2.Qos.ShouldBe(2);
        m2.RetainSeed.ShouldBe(true);
    }

    [Fact]
    public void ToJson_emits_camelCase_keys_with_enum_names_and_no_identity_key()
    {
        var json = new MqttTagConfigModel
        {
            Topic = "a/b",
            PayloadFormat = MqttPayloadFormat.Raw,
            DataType = DriverDataType.Boolean,
        }.ToJson();

        json.ShouldContain("\"topic\":\"a/b\"");
        // Enums MUST serialize as names — the driver factory reads them strictly by name, and an
        // ordinal would be rejected outright (the systemic enum-serialization trap).
        json.ShouldContain("\"payloadFormat\":\"Raw\"");
        json.ShouldContain("\"dataType\":\"Boolean\"");
        json.ShouldNotContain("\"payloadFormat\":1");
        json.ShouldNotContain("\"dataType\":0");
        // TagConfig carries no identity under v3 — the tag's RawPath is the driver-side key.
        json.ShouldNotContain("FullName", Case.Sensitive);
    }

    [Fact]
    public void ToJson_omits_absent_optional_keys()
    {
        var json = new MqttTagConfigModel { Topic = "a/b", PayloadFormat = MqttPayloadFormat.Raw }.ToJson();

        // Absent stays absent so the driver's own defaults (qos ⇒ DefaultQos, retainSeed ⇒ true,
        // jsonPath ⇒ "$") apply, rather than this editor freezing them into the blob.
        json.ShouldNotContain("qos");
        json.ShouldNotContain("retainSeed");
        json.ShouldNotContain("jsonPath");
    }

    [Fact]
    public void ToJson_omits_a_blank_topic()
    {
        // Toggling the shape dropdown on an untouched tag must not leave a stray "topic":"" behind.
        var json = new MqttTagConfigModel { PayloadFormat = MqttPayloadFormat.Raw }.ToJson();

        json.ShouldNotContain("topic");
    }

    [Fact]
    public void FromJson_then_ToJson_preserves_unknown_keys()
    {
        var json = MqttTagConfigModel.FromJson(
            """
            {"topic":"a/b","payloadFormat":"Raw","dataType":"String",
             "isHistorized":true,"historianTagname":"Plant.A.B",
             "array":{"valueRank":1,"dimensions":[4]},
             "alarm":{"type":"Hi","limit":90.5},
             "someFutureScalar":"keep me"}
            """).ToJson();

        var o = JsonNode.Parse(json)!.AsObject();

        // History intent is authored by a different seam (TagHistorizeConfig) — dropping it here would
        // silently un-historize the tag on the next edit.
        o["isHistorized"]!.GetValue<bool>().ShouldBeTrue();
        o["historianTagname"]!.GetValue<string>().ShouldBe("Plant.A.B");
        // Nested objects/arrays must survive structurally, not just as scalars.
        o["array"]!["valueRank"]!.GetValue<int>().ShouldBe(1);
        o["array"]!["dimensions"]!.AsArray()[0]!.GetValue<int>().ShouldBe(4);
        o["alarm"]!["limit"]!.GetValue<double>().ShouldBe(90.5);
        o["someFutureScalar"]!.GetValue<string>().ShouldBe("keep me");
    }

    [Fact]
    public void FromJson_then_ToJson_preserves_sparkplug_descriptor_keys()
    {
        var json = MqttTagConfigModel.FromJson(
            """{"groupId":"Plant1","edgeNodeId":"E1","deviceId":"D1","metricName":"Temp"}""").ToJson();

        var o = JsonNode.Parse(json)!.AsObject();
        o["groupId"]!.GetValue<string>().ShouldBe("Plant1");
        o["edgeNodeId"]!.GetValue<string>().ShouldBe("E1");
        o["deviceId"]!.GetValue<string>().ShouldBe("D1");
        o["metricName"]!.GetValue<string>().ShouldBe("Temp");
        // TagConfig carries no identity under v3 even for a Sparkplug tag — see the corrections in the
        // Task 24 brief: FullName is a retired pre-v3 concept, not something this editor re-introduces.
        json.ShouldNotContain("FullName", Case.Sensitive);
    }

    // ── Validate: Sparkplug rules (Task 24) ─────────────────────────────────────────────────────
    //
    // Mirrors MqttTagDefinitionFactory.FromSparkplugTagConfig exactly: groupId/edgeNodeId/metricName
    // required, deviceId optional, dataType/qos read strictly but only when present. See the model's
    // Validate() remarks for the one place this editor is deliberately STRICTER than the factory
    // (illegal characters in the id segments) and why metricName is exempt from that rule.

    [Fact]
    public void Validate_SparkplugMissingMetricName_Fails()
        => MqttTagConfigModel.FromJson("""{"groupId":"Plant1","edgeNodeId":"EdgeA"}""")
            .Validate().ShouldNotBeNullOrWhiteSpace();

    [Fact]
    public void Validate_SparkplugMissingGroupId_Fails()
        => MqttTagConfigModel.FromJson("""{"edgeNodeId":"EdgeA","metricName":"Temperature"}""")
            .Validate().ShouldNotBeNullOrWhiteSpace();

    [Fact]
    public void Validate_SparkplugMissingEdgeNodeId_Fails()
        => MqttTagConfigModel.FromJson("""{"groupId":"Plant1","metricName":"Temperature"}""")
            .Validate().ShouldNotBeNullOrWhiteSpace();

    [Fact]
    public void Validate_SparkplugValid_ReturnsNull()
        => MqttTagConfigModel.FromJson(
                """{"groupId":"Plant1","edgeNodeId":"EdgeA","deviceId":"Filler1","metricName":"Temperature","dataType":"Float32"}""")
            .Validate().ShouldBeNull();

    // deviceId is genuinely optional per the factory (absent ⇒ a node-level metric) — a Sparkplug tag
    // must validate cleanly without one.
    [Fact]
    public void Validate_SparkplugValidWithoutDeviceId_ReturnsNull()
        => MqttTagConfigModel.FromJson(
                """{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature"}""")
            .Validate().ShouldBeNull();

    // dataType is OPTIONAL in Sparkplug mode (the birth certificate declares it) — unlike Plain mode,
    // an absent dataType is not an error.
    [Fact]
    public void Validate_SparkplugAbsentDataType_ReturnsNull()
        => MqttTagConfigModel.FromJson(
                """{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature"}""")
            .Validate().ShouldBeNull();

    // ...but a PRESENT, typo'd dataType is still rejected — same strict-when-present rule as Plain,
    // and the same DriverDataType vocabulary (not the raw Sparkplug wire enum).
    [Fact]
    public void Validate_SparkplugTypoedDataType_Fails()
        => MqttTagConfigModel.FromJson(
                """{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature","dataType":"Double"}""")
            .Validate().ShouldNotBeNullOrWhiteSpace();

    [Theory]
    [InlineData("3")]
    [InlineData("-1")]
    public void Validate_SparkplugInvalidQos_Fails(string qosLiteral)
        => MqttTagConfigModel.FromJson(
                $$"""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature","qos":{{qosLiteral}}}""")
            .Validate().ShouldNotBeNullOrWhiteSpace();

    // Editor-stricter-than-factory: group/edge-node/device ids are literal MQTT topic segments — a
    // decoded incoming id can never contain '/', '+', or '#', so an authored one that does could never
    // bind. Covers all three id fields plus both illegal characters.
    [Theory]
    [InlineData("groupId", "Plant1/A")]
    [InlineData("groupId", "Plant+1")]
    [InlineData("groupId", "Plant#1")]
    [InlineData("edgeNodeId", "Edge/A")]
    [InlineData("edgeNodeId", "Edge+A")]
    [InlineData("deviceId", "Dev/1")]
    [InlineData("deviceId", "Dev#1")]
    public void Validate_SparkplugIllegalCharacterInIdSegment_Fails(string field, string value)
    {
        var fields = new Dictionary<string, string>
        {
            ["groupId"] = "Plant1",
            ["edgeNodeId"] = "EdgeA",
            ["deviceId"] = "Filler1",
            ["metricName"] = "Temperature",
        };
        fields[field] = value;
        var json = System.Text.Json.JsonSerializer.Serialize(fields);

        MqttTagConfigModel.FromJson(json).Validate().ShouldNotBeNullOrWhiteSpace();
    }

    // metricName is explicitly EXEMPT from the id-segment character rule — it is not a topic segment
    // (it comes from the payload, not the topic), and Sparkplug's own canonical examples use '/' in
    // metric names (e.g. "Node Control/Rebirth"). Over-rejecting here would block legitimate authoring.
    [Fact]
    public void Validate_SparkplugMetricNameWithSlash_IsAccepted()
        => MqttTagConfigModel.FromJson(
                """{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Node Control/Rebirth"}""")
            .Validate().ShouldBeNull();

    // ── ToJson: Sparkplug dataType override (Task 24) ───────────────────────────────────────────

    [Fact]
    public void ToJson_SparkplugWithoutOverride_OmitsDataType()
    {
        var json = MqttTagConfigModel
            .FromJson("""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature"}""")
            .ToJson();

        json.ShouldNotContain("dataType");
    }

    [Fact]
    public void ToJson_Sparkplug_RoundTripsDescriptorFieldsAndDataTypeOverride()
    {
        var json = MqttTagConfigModel.FromJson(
                """{"groupId":"Plant1","edgeNodeId":"EdgeA","deviceId":"Filler1","metricName":"Temperature","dataType":"Float32"}""")
            .ToJson();

        json.ShouldContain("\"groupId\":\"Plant1\"");
        json.ShouldContain("\"edgeNodeId\":\"EdgeA\"");
        json.ShouldContain("\"deviceId\":\"Filler1\"");
        json.ShouldContain("\"metricName\":\"Temperature\"");
        json.ShouldContain("\"dataType\":\"Float32\"");
        json.ShouldNotContain("FullName", Case.Sensitive);
    }

    // A SparkplugB tag has NO 'mode' key to persist — it survives a save→reopen purely because the
    // descriptor keys it writes re-infer the mode on the next load.
    [Fact]
    public void FromJson_then_ToJson_then_FromJson_SparkplugMode_SurvivesReopen()
    {
        var m = MqttTagConfigModel.FromJson(
            """{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature"}""");
        m.Mode.ShouldBe(MqttMode.SparkplugB);

        var json = m.ToJson();
        json.ShouldNotContain("\"mode\"");

        MqttTagConfigModel.FromJson(json).Mode.ShouldBe(MqttMode.SparkplugB);
    }

    [Fact]
    public void FromJson_infers_SparkplugB_mode_from_the_descriptor_keys()
        => MqttTagConfigModel
            .FromJson("""{"groupId":"Plant1","edgeNodeId":"E1","metricName":"Temp"}""")
            .Mode.ShouldBe(MqttMode.SparkplugB);

    // ── Validate: Plain rules ────────────────────────────────────────────────────────────────────

    [Fact]
    public void Validate_ValidPlain_ReturnsNull()
        => MqttTagConfigModel.FromJson(
                """{"topic":"a/b","payloadFormat":"Raw","dataType":"String"}""")
            .Validate().ShouldBeNull();

    [Fact]
    public void Validate_MissingTopic_Fails()
        => MqttTagConfigModel.FromJson("""{"payloadFormat":"Raw","dataType":"String"}""")
            .Validate().ShouldNotBeNullOrWhiteSpace();

    [Theory]
    [InlineData("a/+/c")]
    [InlineData("a/#")]
    public void Validate_PlainWildcardTopic_Fails(string topic)
        => MqttTagConfigModel.FromJson($$"""{"topic":"{{topic}}","payloadFormat":"Raw","dataType":"String"}""")
            .Validate().ShouldNotBeNullOrWhiteSpace();

    // A blank jsonPath under a Json payload is ACCEPTED — the runtime defaults it to the document root
    // ("$"), which is the real "publisher puts a bare JSON scalar on the topic" case. Rejecting it would
    // make the editor refuse what the driver accepts, and would block CSV-import batches (this validator
    // also gates RawManualTagEntryModal's review grid) over a sane default.
    [Fact]
    public void Validate_JsonWithoutPath_IsAccepted()
        => MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Json","dataType":"Float64"}""")
            .Validate().ShouldBeNull();

    // …and the key stays ABSENT through a load→save, so the driver applies its own default rather than
    // this editor freezing "$" into an already-deployed blob.
    [Fact]
    public void ToJson_leaves_an_existing_blank_jsonPath_absent()
    {
        var json = MqttTagConfigModel
            .FromJson("""{"topic":"a/b","payloadFormat":"Json","dataType":"Float64"}""")
            .ToJson();

        json.ShouldNotContain("jsonPath");
    }

    // The seed applies ONLY to an unauthored tag (no topic AND no jsonPath) — a brand-new tag emits an
    // explicit "$" once the operator touches anything, which is the guided half of the deal.
    [Fact]
    public void ToJson_seeds_the_root_jsonPath_on_a_fresh_tag()
    {
        var m = MqttTagConfigModel.FromJson(null);
        m.Topic = "a/b";

        m.ToJson().ShouldContain("\"jsonPath\":\"$\"");
    }

    [Fact]
    public void Validate_JsonWithPath_ReturnsNull()
        => MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Json","jsonPath":"$","dataType":"Float64"}""")
            .Validate().ShouldBeNull();

    [Fact]
    public void Validate_NonJsonFormat_DoesNotRequireJsonPath()
        => MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Scalar","dataType":"Float64"}""")
            .Validate().ShouldBeNull();

    [Theory]
    [InlineData("3")]
    [InlineData("-1")]
    [InlineData("\"high\"")]
    [InlineData("1.5")]
    public void Validate_InvalidQos_Fails(string qosLiteral)
        => MqttTagConfigModel.FromJson($$"""{"topic":"a/b","payloadFormat":"Raw","dataType":"String","qos":{{qosLiteral}}}""")
            .Validate().ShouldNotBeNullOrWhiteSpace();

    [Theory]
    [InlineData(0)]
    [InlineData(1)]
    [InlineData(2)]
    public void Validate_LegalQos_ReturnsNull(int qos)
        => MqttTagConfigModel.FromJson($$"""{"topic":"a/b","payloadFormat":"Raw","dataType":"String","qos":{{qos}}}""")
            .Validate().ShouldBeNull();

    // The driver factory reads payloadFormat/dataType STRICTLY — a typo is rejected outright
    // (BadNodeIdUnknown), never defaulted. The editor must not let such a blob past save while
    // silently retyping it, so Validate reads the ORIGINAL key bag, not the defaulted typed field.
    [Fact]
    public void Validate_TypoedPayloadFormat_Fails()
        => MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Jason","dataType":"String"}""")
            .Validate().ShouldNotBeNullOrWhiteSpace();

    [Fact]
    public void Validate_TypoedDataType_Fails()
        => MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Raw","dataType":"Double"}""")
            .Validate().ShouldNotBeNullOrWhiteSpace();

    // Once the editor has round-tripped the blob the typo is gone (ToJson rewrites the key with the
    // typed field's name), so the strict check above can never wedge a tag the editor itself produced.
    // NB a payloadFormat typo repairs to the Json default, which then legitimately demands a jsonPath —
    // so the typo-repair claim is isolated here on dataType, whose default carries no follow-on rule.
    [Fact]
    public void Validate_AfterEditorRoundTrip_ClearsATypoedEnum()
    {
        var typoed = """{"topic":"a/b","payloadFormat":"Raw","dataType":"Double"}""";
        MqttTagConfigModel.FromJson(typoed).Validate().ShouldNotBeNullOrWhiteSpace();   // pre-condition

        var repaired = MqttTagConfigModel.FromJson(typoed).ToJson();

        repaired.ShouldContain("\"dataType\":\"String\"");
        MqttTagConfigModel.FromJson(repaired).Validate().ShouldBeNull();
    }

    // A well-formed Sparkplug tag is NOT subject to the Plain rules — it has no topic, and applying the
    // Plain "topic is required" check would reject every Sparkplug tag.
    [Fact]
    public void Validate_SparkplugMode_IsNotSubjectToPlainRules()
        => MqttTagConfigModel.FromJson("""{"groupId":"P1","edgeNodeId":"E1","metricName":"Temp"}""")
            .Validate().ShouldBeNull();

    // ── Dispatch registration ────────────────────────────────────────────────────────────────────

    [Fact]
    public void TagConfigEditorMap_resolves_the_Mqtt_editor()
        => TagConfigEditorMap.Resolve("Mqtt").ShouldBe(
            typeof(AdminUI.Components.Shared.Uns.TagEditors.MqttTagConfigEditor));

    [Fact]
    public void TagConfigValidator_dispatches_Mqtt()
        => TagConfigValidator.Validate("Mqtt", """{"payloadFormat":"Raw"}""").ShouldNotBeNullOrWhiteSpace();
}
