using System.Text.Json;
using System.Text.Json.Nodes;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;

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

/// <summary>
///     Round-trip, enum-serialization and validation guards for the MQTT <b>driver</b> config form model.
///     <para>
///     The load-bearing assertion is <see cref="Serialized_blob_binds_onto_MqttDriverOptions_through_the_shared_MqttJson_options"/>:
///     it deserializes this form's output through <see cref="MqttJson.Options"/> — the exact instance
///     <c>MqttDriverFactoryExtensions.CreateInstance</c> and <c>MqttDriverProbe</c> use — so an
///     AdminUI-authored blob that Test-connect accepts cannot fault the deployed driver. That is this
///     repo's documented systemic driver-enum-serialization bug, reproduced as a guard.
///     </para>
/// </summary>
public sealed class MqttDriverFormModelTests
{
    private static JsonObject Parse(string json) => (JsonObject)JsonNode.Parse(json)!;

    private static bool HasKeyIgnoringCase(JsonObject o, string name)
        => o.Any(p => string.Equals(p.Key, name, StringComparison.OrdinalIgnoreCase));

    // --- defaults -------------------------------------------------------------------------------

    [Fact]
    public void Defaults_match_the_driver_options_secure_posture()
    {
        var model = MqttDriverFormModel.FromJson(null);
        var driverDefaults = new MqttDriverOptions();

        model.Port.ShouldBe(driverDefaults.Port);
        model.UseTls.ShouldBeTrue();
        model.UseTls.ShouldBe(driverDefaults.UseTls);
        model.AllowUntrustedServerCertificate.ShouldBeFalse();
        model.AllowUntrustedServerCertificate.ShouldBe(driverDefaults.AllowUntrustedServerCertificate);
        model.ProtocolVersion.ShouldBe(driverDefaults.ProtocolVersion);
        model.Mode.ShouldBe(driverDefaults.Mode);
        model.MaxPayloadBytes.ShouldBe(driverDefaults.MaxPayloadBytes);

        // The redundancy-pair eviction guard: an unauthored driver leaves clientId unset so MQTTnet
        // generates a unique id per connection (P1 live-gate finding).
        model.ClientId.ShouldBe("");
    }

    // --- round-trip -----------------------------------------------------------------------------

    [Fact]
    public void Round_trip_preserves_every_authored_field()
    {
        var model = MqttDriverFormModel.FromJson(null);
        model.Host = "broker.internal";
        model.Port = 1883;
        model.ClientId = "otopcua-site-a";
        model.Username = "otopcua";
        model.Password = "s3cret";
        model.UseTls = false;
        model.AllowUntrustedServerCertificate = true;
        model.CaCertificatePath = "/etc/ssl/ca.pem";
        model.ProtocolVersion = MqttProtocolVersion.V311;
        model.CleanSession = false;
        model.KeepAliveSeconds = 45;
        model.ConnectTimeoutSeconds = 9;
        model.ReconnectMinBackoffSeconds = 2;
        model.ReconnectMaxBackoffSeconds = 60;
        model.Mode = MqttMode.Plain;
        model.MaxPayloadBytes = 4096;
        model.TopicPrefix = "otopcua/fixture/";
        model.DefaultQos = 2;

        var back = MqttDriverFormModel.FromJson(model.ToJson());

        back.Host.ShouldBe("broker.internal");
        back.Port.ShouldBe(1883);
        back.ClientId.ShouldBe("otopcua-site-a");
        back.Username.ShouldBe("otopcua");
        back.Password.ShouldBe("s3cret");
        back.UseTls.ShouldBeFalse();
        back.AllowUntrustedServerCertificate.ShouldBeTrue();
        back.CaCertificatePath.ShouldBe("/etc/ssl/ca.pem");
        back.ProtocolVersion.ShouldBe(MqttProtocolVersion.V311);
        back.CleanSession.ShouldBeFalse();
        back.KeepAliveSeconds.ShouldBe(45);
        back.ConnectTimeoutSeconds.ShouldBe(9);
        back.ReconnectMinBackoffSeconds.ShouldBe(2);
        back.ReconnectMaxBackoffSeconds.ShouldBe(60);
        back.Mode.ShouldBe(MqttMode.Plain);
        back.MaxPayloadBytes.ShouldBe(4096);
        back.TopicPrefix.ShouldBe("otopcua/fixture/");
        back.DefaultQos.ShouldBe(2);
    }

    // --- the factory-parity guard (the point of the whole file) ---------------------------------

    [Fact]
    public void Serialized_blob_binds_onto_MqttDriverOptions_through_the_shared_MqttJson_options()
    {
        var model = MqttDriverFormModel.FromJson(null);
        model.Host = "10.100.0.35";
        model.Port = 8883;
        model.Username = "otopcua";
        model.Password = "pw";
        model.ProtocolVersion = MqttProtocolVersion.V311;
        model.Mode = MqttMode.SparkplugB;
        model.ConnectTimeoutSeconds = 7;
        model.DefaultQos = 0;

        // The exact instance the runtime factory + probe + driver + browser parse through.
        var options = JsonSerializer.Deserialize<MqttDriverOptions>(model.ToJson(), MqttJson.Options);

        options.ShouldNotBeNull();
        options.Host.ShouldBe("10.100.0.35");
        options.Port.ShouldBe(8883);
        options.Username.ShouldBe("otopcua");
        options.Password.ShouldBe("pw");
        options.ProtocolVersion.ShouldBe(MqttProtocolVersion.V311);
        options.Mode.ShouldBe(MqttMode.SparkplugB);
        options.ConnectTimeoutSeconds.ShouldBe(7);
        options.Plain.ShouldNotBeNull();
        options.Plain.DefaultQos.ShouldBe(0);
    }

    [Fact]
    public void Enums_serialize_as_names_never_as_ordinals()
    {
        var model = MqttDriverFormModel.FromJson(null);
        model.Mode = MqttMode.SparkplugB;
        model.ProtocolVersion = MqttProtocolVersion.V311;

        var json = model.ToJson();

        json.ShouldContain("\"Mode\":\"SparkplugB\"");
        json.ShouldContain("\"ProtocolVersion\":\"V311\"");
        // The historical bug: a numerically-serialized enum that the string-typed runtime binder faults on.
        json.ShouldNotContain("\"Mode\":1", Case.Sensitive);
        json.ShouldNotContain("\"ProtocolVersion\":0", Case.Sensitive);
    }

    [Fact]
    public void An_ordinal_only_reader_cannot_bind_the_blob_which_proves_the_enums_are_names()
    {
        // The string-match-free half of the enum pin, and a falsifiability control for the test above.
        // A JsonSerializerOptions WITHOUT a JsonStringEnumConverter can read a NUMERIC enum but not a
        // named one — so if this form ever regressed to writing ordinals (the historical driver bug),
        // this deserialize would start SUCCEEDING and the test would fail. Deliberately independent of
        // MqttJson.Options, because a round-trip through one shared instance is symmetric and would
        // happily pass on ordinals at both ends.
        var model = MqttDriverFormModel.FromJson(null);
        model.Mode = MqttMode.SparkplugB;
        model.ProtocolVersion = MqttProtocolVersion.V311;

        var ordinalOnlyReader = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };

        Should.Throw<JsonException>(
            () => JsonSerializer.Deserialize<MqttDriverOptions>(model.ToJson(), ordinalOnlyReader));
    }

    // --- blob hygiene ---------------------------------------------------------------------------

    [Fact]
    public void RawTags_are_never_written_into_the_driver_config()
    {
        // The deploy artifact injects RawTags via DriverDeviceConfigMerger; a form-authored copy would
        // be dead weight at best and a stale shadow at worst.
        const string inbound = """{"Host":"h","rawTags":[{"RawPath":"a/b","TagConfig":"{}"}]}""";

        var json = MqttDriverFormModel.FromJson(inbound).ToJson();

        HasKeyIgnoringCase(Parse(json), "RawTags").ShouldBeFalse();
    }

    [Fact]
    public void Unknown_keys_and_the_sparkplug_subobject_survive_a_load_save()
    {
        const string inbound = """
            {"Host":"h","sparkplug":{"GroupId":"G1","HostId":"H1","ActAsPrimaryHost":true},"aFutureKey":42}
            """;

        var json = MqttDriverFormModel.FromJson(inbound).ToJson();
        var o = Parse(json);

        // The blob has no Mode key, so it is Plain — and in Plain mode this form does not author the
        // Sparkplug sub-object at all, so it must come back byte-identical.
        o["sparkplug"].ShouldNotBeNull();
        o["sparkplug"]!["GroupId"]!.GetValue<string>().ShouldBe("G1");
        o["sparkplug"]!["ActAsPrimaryHost"]!.GetValue<bool>().ShouldBeTrue();
        o["aFutureKey"]!.GetValue<int>().ShouldBe(42);
    }

    // --- Sparkplug B authoring -------------------------------------------------------------------
    //
    // These pin the defect the P2 live gate found: MqttDriverForm shipped its Sparkplug branch as a
    // "not implemented yet" placeholder, so once Sparkplug ingest landed the group id — the driver's
    // ENTIRE subscription filter, spBv1.0/{GroupId}/# — could not be authored from the AdminUI at all.
    // The driver deployed connected, Healthy, and ingesting nothing.

    [Fact]
    public void Sparkplug_mode_round_trips_the_group_id_through_a_load_save_load()
    {
        var authored = MqttDriverFormModel.FromJson("""{"Host":"h"}""");
        authored.Mode = MqttMode.SparkplugB;
        authored.SparkplugGroupId = "OtOpcUaSim";
        authored.SparkplugRequestRebirthOnGap = true;

        var reloaded = MqttDriverFormModel.FromJson(authored.ToJson());

        reloaded.Mode.ShouldBe(MqttMode.SparkplugB);
        reloaded.SparkplugGroupId.ShouldBe("OtOpcUaSim");
        reloaded.SparkplugRequestRebirthOnGap.ShouldBeTrue();
    }

    [Fact]
    public void Sparkplug_mode_emits_a_group_id_the_driver_options_actually_bind()
    {
        // The whole point of the round-trip: what this form writes must deserialize back through the
        // SAME shared MqttJson.Options the runtime factory uses, or the driver sees a null group.
        var authored = MqttDriverFormModel.FromJson(null);
        authored.Mode = MqttMode.SparkplugB;
        authored.SparkplugGroupId = "OtOpcUaSim";

        var options = JsonSerializer.Deserialize<MqttDriverOptions>(authored.ToJson(), MqttJson.Options)!;

        options.Mode.ShouldBe(MqttMode.SparkplugB);
        options.Sparkplug.ShouldNotBeNull();
        options.Sparkplug!.GroupId.ShouldBe("OtOpcUaSim");
    }

    [Fact]
    public void Sparkplug_authoring_merges_over_the_subobject_rather_than_replacing_it()
    {
        // Same preserve-what-you-do-not-author discipline the top-level bag applies: a key a newer
        // driver adds INSIDE the sub-object must survive an older AdminUI's save.
        const string inbound = """
            {"Host":"h","Mode":"SparkplugB","sparkplug":{"GroupId":"Old","aFutureSpbKey":7}}
            """;

        var model = MqttDriverFormModel.FromJson(inbound);
        model.SparkplugGroupId = "New";

        var o = Parse(model.ToJson());

        o["sparkplug"]!["GroupId"]!.GetValue<string>().ShouldBe("New");
        o["sparkplug"]!["aFutureSpbKey"]!.GetValue<int>().ShouldBe(7);
    }

    [Fact]
    public void A_camelCase_sparkplug_key_is_merged_not_duplicated()
    {
        const string inbound = """{"Host":"h","Mode":"SparkplugB","sparkplug":{"GroupId":"Old"}}""";

        var model = MqttDriverFormModel.FromJson(inbound);
        model.SparkplugGroupId = "New";

        var o = Parse(model.ToJson());

        o.Count(p => string.Equals(p.Key, "Sparkplug", StringComparison.OrdinalIgnoreCase)).ShouldBe(1);
        o["sparkplug"]!["GroupId"]!.GetValue<string>().ShouldBe("New");
    }

    [Fact]
    public void Plain_mode_never_writes_a_sparkplug_subobject_onto_a_blob_that_had_none()
    {
        var model = MqttDriverFormModel.FromJson("""{"Host":"h"}""");
        model.SparkplugGroupId = "TypedThenSwitchedAway";   // Mode is still Plain.

        HasKeyIgnoringCase(Parse(model.ToJson()), "Sparkplug").ShouldBeFalse();
    }

    [Fact]
    public void A_blank_group_id_is_refused_in_sparkplug_mode_only()
    {
        var model = MqttDriverFormModel.FromJson("""{"Host":"h"}""");

        // Plain does not care — it binds by topic.
        model.Validate().ShouldBeNull();

        model.Mode = MqttMode.SparkplugB;
        model.Validate().ShouldNotBeNull();
        model.Validate()!.ShouldContain("Sparkplug group ID is required");
    }

    [Theory]
    [InlineData("Plant/1")]
    [InlineData("Plant+1")]
    [InlineData("Plant#1")]
    public void A_group_id_carrying_a_topic_metacharacter_is_refused(string groupId)
    {
        // It is a literal segment inside spBv1.0/{GroupId}/#, so '/' silently widens the filter and
        // '+'/'#' are not even legal mid-segment. Mirrors MqttTagConfigModel's tag-side rule.
        var model = MqttDriverFormModel.FromJson("""{"Host":"h"}""");
        model.Mode = MqttMode.SparkplugB;
        model.SparkplugGroupId = groupId;

        model.Validate()!.ShouldContain("cannot appear in an MQTT topic segment");
    }

    [Fact]
    public void A_camelCase_inbound_key_is_replaced_not_duplicated()
    {
        // MqttJson.Options is PropertyNameCaseInsensitive, so a hand-edited camelCase blob binds; the
        // form must overwrite that key rather than leave "host" and "Host" fighting in one object.
        const string inbound = """{"host":"old","port":1111}""";

        var model = MqttDriverFormModel.FromJson(inbound);
        model.Host.ShouldBe("old");
        model.Port.ShouldBe(1111);
        model.Host = "new";

        var o = Parse(model.ToJson());

        o.Count(p => string.Equals(p.Key, "Host", StringComparison.OrdinalIgnoreCase)).ShouldBe(1);
        JsonSerializer.Deserialize<MqttDriverOptions>(o.ToJsonString(), MqttJson.Options)!.Host.ShouldBe("new");
    }

    [Fact]
    public void Blank_optional_strings_are_omitted_so_the_driver_defaults_apply()
    {
        var json = MqttDriverFormModel.FromJson(null).ToJson();
        var options = JsonSerializer.Deserialize<MqttDriverOptions>(json, MqttJson.Options)!;

        options.ClientId.ShouldBeNull();
        options.Username.ShouldBeNull();
        options.CaCertificatePath.ShouldBeNull();
    }

    [Fact]
    public void The_merged_deploy_blob_keeps_the_form_connection_and_gains_the_artifact_RawTags()
    {
        // The real deploy seam: DriverDeviceConfigMerger folds this form's DriverConfig together with the
        // (empty) MQTT DeviceConfig and injects the authored raw tags. MQTT authors its connection on the
        // DRIVER precisely so this survives 0, 1 or N devices.
        var model = MqttDriverFormModel.FromJson(null);
        model.Host = "broker.internal";
        model.Port = 1883;
        model.UseTls = false;
        model.Mode = MqttMode.Plain;
        model.DefaultQos = 2;

        var rawTags = new[]
        {
            new RawTagEntry("Plant/Mqtt/Dev1/Temp", """{"topic":"a/b"}""", WriteIdempotent: false, DeviceName: "Dev1"),
        };

        foreach (var devices in new[]
        {
            Array.Empty<DriverDeviceConfigMerger.DeviceRow>(),
            [new DriverDeviceConfigMerger.DeviceRow("Dev1", "{}")],
            [new DriverDeviceConfigMerger.DeviceRow("Dev1", "{}"), new DriverDeviceConfigMerger.DeviceRow("Dev2", "{}")],
        })
        {
            var merged = DriverDeviceConfigMerger.Merge(model.ToJson(), devices, rawTags);
            var options = JsonSerializer.Deserialize<MqttDriverOptions>(merged, MqttJson.Options)!;

            options.Host.ShouldBe("broker.internal", $"device count {devices.Length}");
            options.Port.ShouldBe(1883, $"device count {devices.Length}");
            options.UseTls.ShouldBeFalse($"device count {devices.Length}");
            options.Plain!.DefaultQos.ShouldBe(2, $"device count {devices.Length}");
            options.RawTags.Count.ShouldBe(1, $"device count {devices.Length}");
            options.RawTags[0].RawPath.ShouldBe("Plant/Mqtt/Dev1/Temp");
        }
    }

    // --- validation -----------------------------------------------------------------------------

    [Fact]
    public void Validate_accepts_a_default_model()
        => MqttDriverFormModel.FromJson(null).Validate().ShouldBeNull();

    [Theory]
    [InlineData("Host", "")]
    [InlineData("Port", 0)]
    [InlineData("Port", 70000)]
    [InlineData("KeepAliveSeconds", 0)]
    [InlineData("ConnectTimeoutSeconds", 0)]
    [InlineData("ReconnectMinBackoffSeconds", 0)]
    [InlineData("ReconnectMaxBackoffSeconds", -1)]
    [InlineData("MaxPayloadBytes", 0)]
    [InlineData("DefaultQos", 3)]
    [InlineData("DefaultQos", -1)]
    public void Validate_rejects_an_out_of_range_field(string field, object value)
    {
        var model = MqttDriverFormModel.FromJson(null);
        typeof(MqttDriverFormModel).GetProperty(field)!.SetValue(model, value);

        model.Validate().ShouldNotBeNull();
    }

    [Fact]
    public void Validate_rejects_a_max_backoff_below_the_min()
    {
        var model = MqttDriverFormModel.FromJson(null);
        model.ReconnectMinBackoffSeconds = 30;
        model.ReconnectMaxBackoffSeconds = 5;

        model.Validate().ShouldNotBeNull();
    }

    [Fact]
    public void An_ignored_validation_error_still_cannot_produce_a_driver_bricking_blob()
    {
        // Documented finding: `timeoutSeconds: 0` was an operator-authorable driver-brick. Validate()
        // reports it; the serializer clamps it, so even a saved-anyway blob stays inside the driver's
        // own [Range] bounds.
        var model = MqttDriverFormModel.FromJson(null);
        model.ConnectTimeoutSeconds = 0;
        model.KeepAliveSeconds = -5;
        model.ReconnectMinBackoffSeconds = 0;
        model.ReconnectMaxBackoffSeconds = -3;
        model.MaxPayloadBytes = 0;
        model.Port = 99999;
        model.DefaultQos = 9;

        var options = JsonSerializer.Deserialize<MqttDriverOptions>(model.ToJson(), MqttJson.Options)!;

        options.ConnectTimeoutSeconds.ShouldBeGreaterThanOrEqualTo(1);
        options.KeepAliveSeconds.ShouldBeGreaterThanOrEqualTo(1);
        options.ReconnectMinBackoffSeconds.ShouldBeGreaterThanOrEqualTo(1);
        options.ReconnectMaxBackoffSeconds.ShouldBeGreaterThanOrEqualTo(1);
        options.MaxPayloadBytes.ShouldBeGreaterThanOrEqualTo(1);
        options.Port.ShouldBeInRange(1, 65535);
        options.Plain!.DefaultQos.ShouldBeInRange(0, 2);
    }

    [Fact]
    public void A_malformed_inbound_blob_degrades_to_defaults_rather_than_throwing()
    {
        var model = MqttDriverFormModel.FromJson("}{ not json");

        model.Host.ShouldBe(new MqttDriverOptions().Host);
        model.UseTls.ShouldBeTrue();
    }
}
