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

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

/// <summary>
/// Unit tests for <see cref="RawBrowseCommitMapper"/> — the WP6 browse-leaf → <see cref="RawTagImportRow"/>
/// mapper. Covers the driver data-type mapping, the per-driver address-field placement in the driver-typed
/// <c>TagConfig</c>, and the target-group + folder-mirror path combine.
/// </summary>
[Trait("Category", "Unit")]
public sealed class RawBrowseCommitMapperTests
{
    // ---- MapDataType ----------------------------------------------------------------------------

    [Theory]
    [InlineData("Boolean", "Boolean")]
    [InlineData("Int16", "Int16")]
    [InlineData("Int32", "Int32")]
    [InlineData("Int64", "Int64")]
    [InlineData("UInt16", "UInt16")]
    [InlineData("UInt32", "UInt32")]
    [InlineData("UInt64", "UInt64")]
    [InlineData("Float32", "Float")]
    [InlineData("Float64", "Double")]
    [InlineData("String", "String")]
    [InlineData("DateTime", "DateTime")]
    [InlineData("Reference", "String")]
    public void MapDataType_maps_every_driver_data_type_to_its_opcua_builtin_name(string driverType, string expected)
        => RawBrowseCommitMapper.MapDataType(driverType).ShouldBe(expected);

    [Fact]
    public void MapDataType_is_case_insensitive()
        => RawBrowseCommitMapper.MapDataType("float32").ShouldBe("Float");

    [Theory]
    [InlineData(null)]
    [InlineData("")]
    [InlineData("   ")]
    [InlineData("NotAType")]
    public void MapDataType_returns_null_for_blank_or_unknown(string? input)
        => RawBrowseCommitMapper.MapDataType(input).ShouldBeNull();

    [Fact]
    public void MapDataType_covers_the_whole_DriverDataType_enum()
    {
        // Guard: if a new DriverDataType is added, this fails until MapDataType handles it.
        foreach (var dt in Enum.GetValues<DriverDataType>())
            RawBrowseCommitMapper.MapDataType(dt.ToString()).ShouldNotBeNull(
                $"DriverDataType.{dt} has no OPC-UA type mapping.");
    }

    // ---- BuildTagConfig (address field per driver) ----------------------------------------------

    [Theory]
    [InlineData("OpcUaClient", "nodeId", "ns=2;s=Channel1.Device1.Tag1")]
    [InlineData("AbCip", "tagPath", "Program:Main.Motor.Speed")]
    [InlineData("AbLegacy", "address", "N7:0")]
    [InlineData("TwinCAT", "symbolPath", "MAIN.bStart")]
    [InlineData("FOCAS", "address", "R100")]
    [InlineData("S7", "address", "DB1.DBW0")]
    [InlineData("GalaxyMxGateway", "attributeRef", "Tank_001.Level")]
    public void BuildTagConfig_writes_the_reference_into_the_drivers_address_field(
        string driverType, string expectedKey, string reference)
    {
        var json = RawBrowseCommitMapper.BuildTagConfig(driverType, reference);

        var o = JsonNode.Parse(json)!.AsObject();
        o[expectedKey]!.GetValue<string>().ShouldBe(reference);
    }

    [Fact]
    public void BuildTagConfig_is_case_insensitive_on_driver_type()
    {
        var json = RawBrowseCommitMapper.BuildTagConfig("opcuaclient", "ns=2;s=X");
        JsonNode.Parse(json)!.AsObject()["nodeId"]!.GetValue<string>().ShouldBe("ns=2;s=X");
    }

    [Fact]
    public void BuildTagConfig_does_not_write_any_identity_key()
    {
        // v3: identity is the RawPath — TagConfig must NOT carry a FullName identity key.
        var json = RawBrowseCommitMapper.BuildTagConfig("OpcUaClient", "ns=2;s=X");
        JsonNode.Parse(json)!.AsObject().ContainsKey("FullName").ShouldBeFalse();
    }

    [Fact]
    public void BuildTagConfig_unknown_driver_falls_back_to_a_generic_address_key()
    {
        var json = RawBrowseCommitMapper.BuildTagConfig("Modbus", "40001");
        JsonNode.Parse(json)!.AsObject()["address"]!.GetValue<string>().ShouldBe("40001");
    }

    // ---- BuildTagConfig: MQTT (the address is a DESCRIPTOR, not a single reference string) --------

    /// <summary>The Sparkplug address a browse session states for a metric under a device.</summary>
    /// <param name="metricName">The metric name to state.</param>
    /// <returns>The stated address fields.</returns>
    private static Dictionary<string, string> SparkplugFields(string metricName = "Temperature") => new()
    {
        [MqttTagConfigKeys.GroupId] = "OtOpcUaSim",
        [MqttTagConfigKeys.EdgeNodeId] = "EdgeA",
        [MqttTagConfigKeys.DeviceId] = "Filler1",
        [MqttTagConfigKeys.MetricName] = metricName,
    };

    [Fact]
    public void BuildTagConfig_Mqtt_plain_writes_the_topic_key_the_factory_actually_reads()
    {
        // The generic "address" fallback produced a blob MqttTagDefinitionFactory cannot read at all —
        // a browse-committed tag that deploys clean and then reports BadNodeIdUnknown forever.
        var json = RawBrowseCommitMapper.BuildTagConfig(
            "Mqtt",
            "otopcua/fixture/oven/temp",
            new Dictionary<string, string> { [MqttTagConfigKeys.Topic] = "otopcua/fixture/oven/temp" });

        JsonNode.Parse(json)!.AsObject()["topic"]!.GetValue<string>().ShouldBe("otopcua/fixture/oven/temp");
    }

    [Fact]
    public void BuildTagConfig_Mqtt_plain_falls_back_to_the_node_id_when_no_field_was_stated()
    {
        // A Plain browse node id IS the topic, so the fallback is exact — unlike Sparkplug, where the
        // tuple is unrecoverable and DescribeUncommittableLeaf refuses instead.
        var json = RawBrowseCommitMapper.BuildTagConfig("Mqtt", "otopcua/fixture/oven/temp");

        JsonNode.Parse(json)!.AsObject()["topic"]!.GetValue<string>().ShouldBe("otopcua/fixture/oven/temp");
    }

    [Fact]
    public void BuildTagConfig_Mqtt_sparkplug_writes_the_binding_tuple_from_the_stated_fields()
    {
        var json = RawBrowseCommitMapper.BuildTagConfig(
            "Mqtt", "OtOpcUaSim/EdgeA/Filler1::Temperature", SparkplugFields());

        var o = JsonNode.Parse(json)!.AsObject();
        o["groupId"]!.GetValue<string>().ShouldBe("OtOpcUaSim");
        o["edgeNodeId"]!.GetValue<string>().ShouldBe("EdgeA");
        o["deviceId"]!.GetValue<string>().ShouldBe("Filler1");
        o["metricName"]!.GetValue<string>().ShouldBe("Temperature");
        o.ContainsKey("topic").ShouldBeFalse();   // a Sparkplug tag has no per-tag topic
        o.ContainsKey("address").ShouldBeFalse(); // and never the generic fallback key
    }

    [Fact]
    public void BuildTagConfig_Mqtt_sparkplug_node_level_metric_omits_deviceId_entirely()
    {
        var fields = SparkplugFields();
        fields.Remove(MqttTagConfigKeys.DeviceId);

        var json = RawBrowseCommitMapper.BuildTagConfig("Mqtt", "OtOpcUaSim/EdgeA::Temperature", fields);

        JsonNode.Parse(json)!.AsObject().ContainsKey("deviceId").ShouldBeFalse();
    }

    [Fact]
    public void BuildTagConfig_Mqtt_sparkplug_takes_the_metric_name_from_the_fields_not_the_node_id()
    {
        // THE case the whole seam exists for: a metric name containing '/' makes the node id
        // '{group}/{edge}/{device}::{metric}' un-splittable — any parse would bind the wrong metric.
        var json = RawBrowseCommitMapper.BuildTagConfig(
            "Mqtt",
            "OtOpcUaSim/EdgeA/Filler1::Node Control/Rebirth",
            SparkplugFields("Node Control/Rebirth"));

        var o = JsonNode.Parse(json)!.AsObject();
        o["metricName"]!.GetValue<string>().ShouldBe("Node Control/Rebirth");
        o["deviceId"]!.GetValue<string>().ShouldBe("Filler1");
    }

    [Fact]
    public void BuildTagConfig_Mqtt_sparkplug_survives_a_group_id_containing_the_metric_separator()
    {
        // A group id is an arbitrary MQTT topic segment and MAY contain '::'. Splitting the node id on
        // the first '::' would read the group as 'odd' and the metric as 'group/EdgeA::Temperature'.
        var fields = SparkplugFields();
        fields[MqttTagConfigKeys.GroupId] = "odd::group";

        var json = RawBrowseCommitMapper.BuildTagConfig(
            "Mqtt", "odd::group/EdgeA/Filler1::Temperature", fields);

        var o = JsonNode.Parse(json)!.AsObject();
        o["groupId"]!.GetValue<string>().ShouldBe("odd::group");
        o["metricName"]!.GetValue<string>().ShouldBe("Temperature");
    }

    [Fact]
    public void BuildTagConfig_Mqtt_sparkplug_blob_deserializes_through_the_REAL_driver_factory()
    {
        // The round trip that would have caught the defect: the committed blob is fed to the factory the
        // deployed driver actually uses. Anything else is a test of the mapper agreeing with itself.
        var json = RawBrowseCommitMapper.BuildTagConfig(
            "Mqtt",
            "OtOpcUaSim/EdgeA/Filler1::Node Control/Rebirth",
            SparkplugFields("Node Control/Rebirth"));

        MqttTagDefinitionFactory
            .FromSparkplugTagConfig(json, "Plant/Mqtt/dev1/Rebirth", out var def)
            .ShouldBeTrue();

        def.Name.ShouldBe("Plant/Mqtt/dev1/Rebirth");   // v3: identity is the RawPath
        def.GroupId.ShouldBe("OtOpcUaSim");
        def.EdgeNodeId.ShouldBe("EdgeA");
        def.DeviceId.ShouldBe("Filler1");
        def.MetricName.ShouldBe("Node Control/Rebirth");
        def.DataTypeAuthored.ShouldBeFalse();           // no dataType key ⇒ take the birth's declared type
    }

    [Fact]
    public void BuildTagConfig_Mqtt_plain_blob_deserializes_through_the_REAL_driver_factory()
    {
        var json = RawBrowseCommitMapper.BuildTagConfig(
            "Mqtt",
            "otopcua/fixture/oven/temp",
            new Dictionary<string, string> { [MqttTagConfigKeys.Topic] = "otopcua/fixture/oven/temp" });

        MqttTagDefinitionFactory.FromTagConfig(json, "Plant/Mqtt/dev1/Temp", out var def).ShouldBeTrue();
        def.Topic.ShouldBe("otopcua/fixture/oven/temp");
        def.Name.ShouldBe("Plant/Mqtt/dev1/Temp");
    }

    [Fact]
    public void The_round_trip_test_is_falsifiable_a_wrong_key_name_is_rejected_by_the_factory()
    {
        // Control: mutate the emitted key name (what the pre-fix mapper effectively did, writing
        // "address") and the factory must REFUSE it. Without this, the round-trip assertions above could
        // pass off any blob the factory happened to tolerate.
        var wrong = new JsonObject
        {
            ["group"] = "OtOpcUaSim",           // not "groupId"
            ["edgeNodeId"] = "EdgeA",
            ["metricName"] = "Temperature",
        }.ToJsonString();

        MqttTagDefinitionFactory.FromSparkplugTagConfig(wrong, "Plant/Mqtt/dev1/T", out _).ShouldBeFalse();

        // And the exact blob the pre-fix generic fallback produced.
        var legacy = new JsonObject { ["address"] = "OtOpcUaSim/EdgeA/Filler1::Temperature" }.ToJsonString();
        MqttTagDefinitionFactory.FromSparkplugTagConfig(legacy, "Plant/Mqtt/dev1/T", out _).ShouldBeFalse();
        MqttTagDefinitionFactory.FromTagConfig(legacy, "Plant/Mqtt/dev1/T", out _).ShouldBeFalse();
    }

    [Fact]
    public void DescribeUncommittableLeaf_refuses_an_mqtt_leaf_whose_address_was_never_stated()
    {
        // No stated address ⇒ no bindable tag. Failing here, in words, is the whole point: the old path
        // committed it silently and the operator learned about it as a runtime BadNodeIdUnknown.
        var error = RawBrowseCommitMapper.DescribeUncommittableLeaf("Mqtt", "Temperature", addressFields: null);

        error.ShouldNotBeNull();
        error!.ShouldContain("Temperature");
    }

    [Theory]
    [InlineData(MqttTagConfigKeys.Topic, "otopcua/fixture/oven/temp")]
    [InlineData(MqttTagConfigKeys.MetricName, "Temperature")]
    public void DescribeUncommittableLeaf_accepts_either_stated_mqtt_shape(string key, string value)
        => RawBrowseCommitMapper
            .DescribeUncommittableLeaf("Mqtt", "leaf", new Dictionary<string, string> { [key] = value })
            .ShouldBeNull();

    [Fact]
    public void DescribeUncommittableLeaf_never_blocks_a_single_reference_driver()
    {
        // Every other driver's address IS the node id — they state no fields and must stay committable.
        foreach (var driver in new[] { "OpcUaClient", "AbCip", "TwinCAT", "S7", "GalaxyMxGateway", "Modbus" })
            RawBrowseCommitMapper.DescribeUncommittableLeaf(driver, "leaf", addressFields: null).ShouldBeNull();
    }

    [Fact]
    public void MapLeaf_flows_the_stated_address_fields_into_the_committed_row()
    {
        var row = RawBrowseCommitMapper.MapLeaf(
            driverType: "Mqtt",
            fullName: "OtOpcUaSim/EdgeA/Filler1::Temperature",
            browseName: "Temperature",
            driverDataType: "Float32",
            defaultDataType: "Double",
            groupPrefix: null,
            folderPath: new[] { "OtOpcUaSim", "EdgeA", "Filler1" },
            createGroups: false,
            addressFields: SparkplugFields());

        row.Tag.Name.ShouldBe("Temperature");
        row.Tag.DataType.ShouldBe("Float");
        MqttTagDefinitionFactory
            .FromSparkplugTagConfig(row.Tag.TagConfig, "Plant/Mqtt/dev1/Temperature", out var def)
            .ShouldBeTrue();
        def.MetricName.ShouldBe("Temperature");
    }

    // ---- CombineGroupPath -----------------------------------------------------------------------

    [Theory]
    [InlineData(null, null, null)]
    [InlineData("Grp", null, "Grp")]
    [InlineData(null, "Sub", "Sub")]
    [InlineData("Grp", "Sub", "Grp/Sub")]
    [InlineData("Grp/Deep", "Sub/Leaf", "Grp/Deep/Sub/Leaf")]
    [InlineData("  ", "Sub", "Sub")]
    public void CombineGroupPath_joins_prefix_and_suffix(string? prefix, string? suffix, string? expected)
        => RawBrowseCommitMapper.CombineGroupPath(prefix, suffix).ShouldBe(expected);

    // ---- MapLeaf (end to end) -------------------------------------------------------------------

    [Fact]
    public void MapLeaf_builds_a_read_row_with_typed_config_and_no_group_at_device_root()
    {
        var row = RawBrowseCommitMapper.MapLeaf(
            driverType: "TwinCAT",
            fullName: "MAIN.rSpeed",
            browseName: "rSpeed",
            driverDataType: "Float32",
            defaultDataType: "Double",
            groupPrefix: null,
            folderPath: new[] { "MAIN" },
            createGroups: false);

        row.TagGroupPath.ShouldBeNull();                 // mirror OFF, no target group
        row.Tag.Name.ShouldBe("rSpeed");
        row.Tag.DataType.ShouldBe("Float");              // Float32 -> Float
        row.Tag.AccessLevel.ShouldBe(TagAccessLevel.Read);
        row.Tag.WriteIdempotent.ShouldBeFalse();
        row.Tag.PollGroupId.ShouldBeNull();
        JsonNode.Parse(row.Tag.TagConfig)!.AsObject()["symbolPath"]!.GetValue<string>().ShouldBe("MAIN.rSpeed");
    }

    [Fact]
    public void MapLeaf_mirrors_folder_path_onto_group_when_create_groups_on()
    {
        var row = RawBrowseCommitMapper.MapLeaf(
            driverType: "AbCip",
            fullName: "Program:Main.Motor.Speed",
            browseName: "Speed",
            driverDataType: "Int32",
            defaultDataType: "Double",
            groupPrefix: null,
            folderPath: new[] { "Program:Main", "Motor" },
            createGroups: true);

        row.TagGroupPath.ShouldBe("Program:Main/Motor");
    }

    [Fact]
    public void MapLeaf_prepends_target_group_prefix_and_mirror()
    {
        var row = RawBrowseCommitMapper.MapLeaf(
            driverType: "AbCip",
            fullName: "Program:Main.Motor.Speed",
            browseName: "Speed",
            driverDataType: "Int32",
            defaultDataType: "Double",
            groupPrefix: "Imported",
            folderPath: new[] { "Motor" },
            createGroups: true);

        row.TagGroupPath.ShouldBe("Imported/Motor");
    }

    [Fact]
    public void MapLeaf_uses_target_group_prefix_only_when_mirror_off()
    {
        var row = RawBrowseCommitMapper.MapLeaf(
            driverType: "AbCip",
            fullName: "Program:Main.Motor.Speed",
            browseName: "Speed",
            driverDataType: "Int32",
            defaultDataType: "Double",
            groupPrefix: "Imported",
            folderPath: new[] { "Motor" },
            createGroups: false);

        row.TagGroupPath.ShouldBe("Imported");
    }

    [Fact]
    public void MapLeaf_falls_back_to_default_data_type_when_browser_reports_none()
    {
        // OPC UA Client browse reports no attribute type — the default fills in.
        var row = RawBrowseCommitMapper.MapLeaf(
            driverType: "OpcUaClient",
            fullName: "ns=2;s=Tag1",
            browseName: "Tag1",
            driverDataType: null,
            defaultDataType: "Double",
            groupPrefix: null,
            folderPath: null,
            createGroups: true);       // mirror ON but null folderPath -> still device root

        row.TagGroupPath.ShouldBeNull();
        row.Tag.DataType.ShouldBe("Double");
        JsonNode.Parse(row.Tag.TagConfig)!.AsObject()["nodeId"]!.GetValue<string>().ShouldBe("ns=2;s=Tag1");
    }
}
