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.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Commons.Csv;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;

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

/// <summary>
/// Property test for the WP5 CSV export→import round-trip: <see cref="RawTagCsvMapper.Export"/> a device's
/// tags then re-parse the produced CSV with <see cref="RawTagCsvMapper.Parse"/> and assert the reconstructed
/// tag set is identical (name / group path / data type / access / write flag / poll group and a semantically
/// equal <c>TagConfig</c>). Exercises typed driver columns, the historize/array/alarm flag columns, the
/// residual <c>TagConfigJson</c> fallback (typed-wins), and the model-less (Galaxy) raw-key map.
/// </summary>
[Trait("Category", "Unit")]
public sealed class RawTagCsvRoundTripTests
{
    [Fact]
    public void Modbus_device_round_trips_typed_columns_flags_and_residual()
    {
        // A rich Modbus config: every typed column + historize + array + alarm + a residual ("scale")
        // key the TagConfigJson fallback must carry.
        var richTag = new RawCsvExportTag(
            TagGroupPath: "Bank1/Analog",
            Name: "flow-01",
            DataType: "Float",
            AccessLevel: TagAccessLevel.ReadWrite,
            WriteIdempotent: true,
            PollGroupId: "fast",
            TagConfig: """
                {"region":"InputRegisters","address":10,"dataType":"Float32","byteOrder":"WordSwap",
                 "bitIndex":0,"stringLength":0,"writable":false,"scale":3.5,
                 "isHistorized":true,"historianTagname":"MyHist","isArray":true,"arrayLength":4,
                 "alarm":{"alarmType":"LimitAlarm","severity":800,"historizeToAveva":false}}
                """);

        // A device-root tag (null group path) with only typed columns + no flags.
        var plainTag = new RawCsvExportTag(
            TagGroupPath: null,
            Name: "coil-3",
            DataType: "Boolean",
            AccessLevel: TagAccessLevel.Read,
            WriteIdempotent: false,
            PollGroupId: null,
            TagConfig: ModbusModel("Coils", 3, "Bool", "BigEndian", writable: null));

        AssertRoundTrip(DriverTypeNames.Modbus, new[] { richTag, plainTag });
    }

    [Fact]
    public void OpcUaClient_and_Galaxy_maps_round_trip()
    {
        var opc = new RawCsvExportTag(
            "Line/Motors", "m1", "Double", TagAccessLevel.ReadWrite, false, null,
            """{"nodeId":"nsu=urn:plc;s=Motor1.Speed","isHistorized":true}""");

        AssertRoundTrip(DriverTypeNames.OpcUaClient, new[] { opc });

        var galaxy = new RawCsvExportTag(
            null, "Reactor_Temp", "Float", TagAccessLevel.Read, false, null,
            """{"FullName":"Reactor_001.Temp","historianTagname":"Reactor.Temp"}""");

        AssertRoundTrip(DriverTypeNames.Galaxy, new[] { galaxy });
    }

    [Fact]
    public void Export_header_matches_the_driver_column_dictionary()
    {
        var csv = RawTagCsvMapper.Export(DriverTypeNames.S7, Array.Empty<RawCsvExportTag>());
        var rows = CsvParser.Parse(csv);
        rows.ShouldHaveSingleItem(); // header only
        rows[0].ShouldBe(CsvColumnMap.HeadersFor(DriverTypeNames.S7).ToArray());
    }

    [Fact]
    public void A_typed_column_wins_over_a_conflicting_TagConfigJson_key()
    {
        // Base carries address="DB1.DBW99"; the typed Address column carries "DB1.DBW5" — the typed value
        // must win, and the review row must flag the override. (S7 Address is a string field.)
        var csv = string.Join("\r\n",
            "Name,DataType,Address,S7DataType,TagConfigJson",
            """flow,Int16,DB1.DBW5,Int16,"{""address"":""DB1.DBW99""}" """.Trim());

        var result = RawTagCsvMapper.Parse(DriverTypeNames.S7, csv);
        result.FatalError.ShouldBeNull();
        var row = result.Rows.ShouldHaveSingleItem();
        row.Ok.ShouldBeTrue();
        row.TypedOverrodeFallback.ShouldContain("Address");

        var o = JsonNode.Parse(row.ImportRow!.Tag.TagConfig)!.AsObject();
        o["address"]!.GetValue<string>().ShouldBe("DB1.DBW5");
    }

    [Fact]
    public void A_bad_enum_row_is_flagged_and_blocks_commit()
    {
        var csv = string.Join("\r\n",
            "Name,DataType,Region,Address,ModbusDataType",
            "t1,Int16,NotARegion,0,Int16");

        var result = RawTagCsvMapper.Parse(DriverTypeNames.Modbus, csv);
        var row = result.Rows.ShouldHaveSingleItem();
        row.Ok.ShouldBeFalse();
        row.Error!.ShouldContain("Region");
        result.CanCommit.ShouldBeFalse();
    }

    [Fact]
    public void Duplicate_name_in_the_same_group_is_flagged_on_the_second_row()
    {
        var csv = string.Join("\r\n",
            "Name,DataType,TagGroupPath,NodeId",
            "dup,Int32,G1,ns=2;s=A",
            "dup,Int32,G1,ns=2;s=B");

        var result = RawTagCsvMapper.Parse(DriverTypeNames.OpcUaClient, csv);
        result.Rows[0].Ok.ShouldBeTrue();
        result.Rows[1].Ok.ShouldBeFalse();
        result.Rows[1].Error!.ShouldContain("Duplicate");
        result.CanCommit.ShouldBeFalse();
    }

    [Fact]
    public void Group_path_prefix_is_prepended_to_each_row()
    {
        var csv = string.Join("\r\n",
            "Name,DataType,TagGroupPath,NodeId",
            "a,Int32,Sub,ns=2;s=A",
            "b,Int32,,ns=2;s=B");

        var result = RawTagCsvMapper.Parse(DriverTypeNames.OpcUaClient, csv, groupPathPrefix: "Root");
        result.Rows[0].ImportRow!.TagGroupPath.ShouldBe("Root/Sub");
        result.Rows[1].ImportRow!.TagGroupPath.ShouldBe("Root");
    }

    // -------------------------------------------------------------------------------------- helpers

    private static void AssertRoundTrip(string driverType, IReadOnlyList<RawCsvExportTag> tags)
    {
        var csv = RawTagCsvMapper.Export(driverType, tags);
        var parsed = RawTagCsvMapper.Parse(driverType, csv);

        parsed.FatalError.ShouldBeNull();
        parsed.CanCommit.ShouldBeTrue($"every re-imported row should be valid; errors: " +
            string.Join(" | ", parsed.Rows.Where(r => !r.Ok).Select(r => r.Error)));

        var imported = parsed.ToImportRows();
        imported.Count.ShouldBe(tags.Count);

        foreach (var original in tags)
        {
            var match = imported.SingleOrDefault(r => r.Tag.Name == original.Name);
            match.ShouldNotBeNull($"tag '{original.Name}' vanished across the round-trip.");

            match!.TagGroupPath.ShouldBe(original.TagGroupPath, $"group path drift for '{original.Name}'.");
            match.Tag.DataType.ShouldBe(original.DataType);
            match.Tag.AccessLevel.ShouldBe(original.AccessLevel);
            match.Tag.WriteIdempotent.ShouldBe(original.WriteIdempotent);
            match.Tag.PollGroupId.ShouldBe(original.PollGroupId);

            Canonical(match.Tag.TagConfig).ShouldBe(Canonical(original.TagConfig),
                $"TagConfig drift for '{original.Name}'.");
        }
    }

    // Canonical Modbus config via the real model, so the "expected" already carries the model's full key set.
    private static string ModbusModel(string region, int address, string dataType, string byteOrder, bool? writable)
    {
        var m = ModbusTagConfigModel.FromJson("{}");
        m.Region = Enum.Parse<ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusRegion>(region);
        m.Address = address;
        m.DataType = Enum.Parse<ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusDataType>(dataType);
        m.ByteOrder = Enum.Parse<ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusByteOrder>(byteOrder);
        m.Writable = writable;
        return m.ToJson();
    }

    // Order-independent, numeric-normalised JSON canonicaliser for semantic equality.
    private static string Canonical(string json)
    {
        var node = JsonNode.Parse(json);
        return Normalize(node)?.ToJsonString() ?? "null";
    }

    private static JsonNode? Normalize(JsonNode? node)
    {
        switch (node)
        {
            case JsonObject obj:
            {
                var sorted = new JsonObject();
                foreach (var key in obj.Select(kvp => kvp.Key).OrderBy(k => k, StringComparer.Ordinal))
                {
                    sorted[key] = Normalize(obj[key]?.DeepClone());
                }

                return sorted;
            }

            case JsonArray arr:
            {
                var outArr = new JsonArray();
                foreach (var item in arr)
                {
                    outArr.Add(Normalize(item?.DeepClone()));
                }

                return outArr;
            }

            case JsonValue val:
            {
                // Collapse numeric representations (int vs uint vs double 4 vs 4.0) to a canonical form.
                if (val.GetValueKind() == JsonValueKind.Number &&
                    double.TryParse(val.ToJsonString(), System.Globalization.NumberStyles.Any,
                        System.Globalization.CultureInfo.InvariantCulture, out var d))
                {
                    return JsonValue.Create(d);
                }

                return val;
            }

            default:
                return node;
        }
    }
}
