using System.Buffers.Binary;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;

namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;

[Trait("Category", "Unit")]
public sealed class ModbusDriverTests
{
    /// <summary>
    ///     In-memory Modbus TCP server impl that speaks the function codes the driver uses.
    ///     Maintains a register/coil bank so Read/Write round-trips work. Internal (rather than
    ///     private) so sibling test files in this project can reuse it without duplicating the
    ///     fake.
    /// </summary>
    internal sealed class FakeTransport : IModbusTransport
    {
        public readonly ushort[] HoldingRegisters = new ushort[256];
        public readonly ushort[] InputRegisters = new ushort[256];
        public readonly bool[] Coils = new bool[256];
        public readonly bool[] DiscreteInputs = new bool[256];
        /// <summary>Gets or sets a value indicating whether connect operations should fail.</summary>
        public bool ForceConnectFail { get; set; }

        /// <summary>Initiates a connection to the Modbus server.</summary>
        /// <param name="ct">Cancellation token.</param>
        public Task ConnectAsync(CancellationToken ct)
            => ForceConnectFail ? Task.FromException(new InvalidOperationException("connect refused")) : Task.CompletedTask;

        /// <summary>Sends a Modbus PDU and receives the response.</summary>
        /// <param name="unitId">Modbus unit ID.</param>
        /// <param name="pdu">Protocol data unit bytes to send.</param>
        /// <param name="ct">Cancellation token.</param>
        public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
        {
            var fc = pdu[0];
            return fc switch
            {
                0x01 => Task.FromResult(ReadBits(pdu, Coils)),
                0x02 => Task.FromResult(ReadBits(pdu, DiscreteInputs)),
                0x03 => Task.FromResult(ReadRegs(pdu, HoldingRegisters)),
                0x04 => Task.FromResult(ReadRegs(pdu, InputRegisters)),
                0x05 => Task.FromResult(WriteCoil(pdu)),
                0x06 => Task.FromResult(WriteSingleReg(pdu)),
                0x0F => Task.FromResult(WriteMultipleCoils(pdu)),
                0x10 => Task.FromResult(WriteMultipleRegs(pdu)),
                _ => Task.FromException<byte[]>(new ModbusException(fc, 0x01, $"fc={fc} not supported by fake")),
            };
        }

        private byte[] ReadBits(byte[] pdu, bool[] bank)
        {
            var addr = (ushort)((pdu[1] << 8) | pdu[2]);
            var qty = (ushort)((pdu[3] << 8) | pdu[4]);
            var byteCount = (byte)((qty + 7) / 8);
            var resp = new byte[2 + byteCount];
            resp[0] = pdu[0];
            resp[1] = byteCount;
            for (var i = 0; i < qty; i++)
                if (bank[addr + i]) resp[2 + (i / 8)] |= (byte)(1 << (i % 8));
            return resp;
        }

        private byte[] ReadRegs(byte[] pdu, ushort[] bank)
        {
            var addr = (ushort)((pdu[1] << 8) | pdu[2]);
            var qty = (ushort)((pdu[3] << 8) | pdu[4]);
            var byteCount = (byte)(qty * 2);
            var resp = new byte[2 + byteCount];
            resp[0] = pdu[0];
            resp[1] = byteCount;
            for (var i = 0; i < qty; i++)
            {
                resp[2 + i * 2] = (byte)(bank[addr + i] >> 8);
                resp[3 + i * 2] = (byte)(bank[addr + i] & 0xFF);
            }
            return resp;
        }

        private byte[] WriteCoil(byte[] pdu)
        {
            var addr = (ushort)((pdu[1] << 8) | pdu[2]);
            Coils[addr] = pdu[3] == 0xFF;
            return pdu; // Modbus echoes the request on write success
        }

        private byte[] WriteSingleReg(byte[] pdu)
        {
            var addr = (ushort)((pdu[1] << 8) | pdu[2]);
            HoldingRegisters[addr] = (ushort)((pdu[3] << 8) | pdu[4]);
            return pdu;
        }

        private byte[] WriteMultipleRegs(byte[] pdu)
        {
            var addr = (ushort)((pdu[1] << 8) | pdu[2]);
            var qty = (ushort)((pdu[3] << 8) | pdu[4]);
            for (var i = 0; i < qty; i++)
                HoldingRegisters[addr + i] = (ushort)((pdu[6 + i * 2] << 8) | pdu[7 + i * 2]);
            return new byte[] { 0x10, pdu[1], pdu[2], pdu[3], pdu[4] };
        }

        private byte[] WriteMultipleCoils(byte[] pdu)
        {
            var addr = (ushort)((pdu[1] << 8) | pdu[2]);
            var qty = (ushort)((pdu[3] << 8) | pdu[4]);
            for (var i = 0; i < qty; i++)
                Coils[addr + i] = ((pdu[6 + (i / 8)] >> (i % 8)) & 0x01) == 1;
            return new byte[] { 0x0F, pdu[1], pdu[2], pdu[3], pdu[4] };
        }

        /// <summary>Disposes the transport asynchronously.</summary>
        public ValueTask DisposeAsync() => ValueTask.CompletedTask;
    }

    private static (ModbusDriver driver, FakeTransport fake) NewDriver(params ModbusTagDefinition[] tags)
    {
        var fake = new FakeTransport();
        var opts = new ModbusDriverOptions { Host = "fake", RawTags = ModbusRawTags.Entries(tags)};
        var drv = new ModbusDriver(opts, "modbus-1", _ => fake);
        return (drv, fake);
    }

    /// <summary>Verifies that Initialize connects and populates the tag map.</summary>
    [Fact]
    public async Task Initialize_connects_and_populates_tag_map()
    {
        var (drv, _) = NewDriver(
            new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16),
            new ModbusTagDefinition("Run", ModbusRegion.Coils, 0, ModbusDataType.Bool));
        await drv.InitializeAsync("{}", CancellationToken.None);
        drv.GetHealth().State.ShouldBe(DriverState.Healthy);
    }

    /// <summary>Verifies that reading Int16 holding registers returns big-endian values correctly.</summary>
    [Fact]
    public async Task Read_Int16_holding_register_returns_BigEndian_value()
    {
        var (drv, fake) = NewDriver(new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 10, ModbusDataType.Int16));
        await drv.InitializeAsync("{}", CancellationToken.None);
        fake.HoldingRegisters[10] = 12345;

        var r = await drv.ReadAsync(["Level"], CancellationToken.None);
        r[0].Value.ShouldBe((short)12345);
        r[0].StatusCode.ShouldBe(0u);
    }

    /// <summary>Verifies that reading Float32 values spans two registers in big-endian format.</summary>
    [Fact]
    public async Task Read_Float32_spans_two_registers_BigEndian()
    {
        var (drv, fake) = NewDriver(new ModbusTagDefinition("Temp", ModbusRegion.HoldingRegisters, 4, ModbusDataType.Float32));
        await drv.InitializeAsync("{}", CancellationToken.None);

        // IEEE 754 single for 25.5f is 0x41CC0000 — [41 CC][00 00] big-endian across two regs.
        var bytes = new byte[4];
        BinaryPrimitives.WriteSingleBigEndian(bytes, 25.5f);
        fake.HoldingRegisters[4] = (ushort)((bytes[0] << 8) | bytes[1]);
        fake.HoldingRegisters[5] = (ushort)((bytes[2] << 8) | bytes[3]);

        var r = await drv.ReadAsync(["Temp"], CancellationToken.None);
        r[0].Value.ShouldBe(25.5f);
    }

    /// <summary>Verifies that reading coils returns boolean values.</summary>
    [Fact]
    public async Task Read_Coil_returns_boolean()
    {
        var (drv, fake) = NewDriver(new ModbusTagDefinition("Run", ModbusRegion.Coils, 3, ModbusDataType.Bool));
        await drv.InitializeAsync("{}", CancellationToken.None);
        fake.Coils[3] = true;

        var r = await drv.ReadAsync(["Run"], CancellationToken.None);
        r[0].Value.ShouldBe(true);
    }

    /// <summary>Verifies that reading unknown tags returns BadNodeIdUnknown status instead of throwing.</summary>
    [Fact]
    public async Task Unknown_tag_returns_BadNodeIdUnknown_not_an_exception()
    {
        var (drv, _) = NewDriver();
        await drv.InitializeAsync("{}", CancellationToken.None);

        var r = await drv.ReadAsync(["DoesNotExist"], CancellationToken.None);
        r[0].StatusCode.ShouldBe(0x80340000u);
    }

    /// <summary>Verifies that writing UInt16 holding registers round-trips correctly.</summary>
    [Fact]
    public async Task Write_UInt16_holding_register_roundtrips()
    {
        var (drv, fake) = NewDriver(new ModbusTagDefinition("Setpoint", ModbusRegion.HoldingRegisters, 20, ModbusDataType.UInt16));
        await drv.InitializeAsync("{}", CancellationToken.None);

        var results = await drv.WriteAsync([new WriteRequest("Setpoint", (ushort)42000)], CancellationToken.None);
        results[0].StatusCode.ShouldBe(0u);
        fake.HoldingRegisters[20].ShouldBe((ushort)42000);
    }

    /// <summary>Verifies that writing Float32 values uses function code 16 (WriteMultipleRegisters).</summary>
    [Fact]
    public async Task Write_Float32_uses_FC16_WriteMultipleRegisters()
    {
        var (drv, fake) = NewDriver(new ModbusTagDefinition("Temp", ModbusRegion.HoldingRegisters, 4, ModbusDataType.Float32));
        await drv.InitializeAsync("{}", CancellationToken.None);

        await drv.WriteAsync([new WriteRequest("Temp", 25.5f)], CancellationToken.None);

        // Decode back through the fake bank to check the two-register shape.
        var raw = new byte[4];
        raw[0] = (byte)(fake.HoldingRegisters[4] >> 8);
        raw[1] = (byte)(fake.HoldingRegisters[4] & 0xFF);
        raw[2] = (byte)(fake.HoldingRegisters[5] >> 8);
        raw[3] = (byte)(fake.HoldingRegisters[5] & 0xFF);
        BinaryPrimitives.ReadSingleBigEndian(raw).ShouldBe(25.5f);
    }

    /// <summary>Verifies that writing to input registers returns BadNotWritable status.</summary>
    [Fact]
    public async Task Write_to_InputRegister_returns_BadNotWritable()
    {
        var (drv, _) = NewDriver(new ModbusTagDefinition("Ro", ModbusRegion.InputRegisters, 0, ModbusDataType.UInt16, Writable: false));
        await drv.InitializeAsync("{}", CancellationToken.None);

        var r = await drv.WriteAsync([new WriteRequest("Ro", (ushort)7)], CancellationToken.None);
        r[0].StatusCode.ShouldBe(0x803B0000u);
    }

    /// <summary>Verifies that Discover streams one folder per driver with a variable per tag.</summary>
    [Fact]
    public async Task Discover_streams_one_folder_per_driver_with_a_variable_per_tag()
    {
        var (drv, _) = NewDriver(
            new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16),
            new ModbusTagDefinition("Temp", ModbusRegion.HoldingRegisters, 4, ModbusDataType.Float32),
            new ModbusTagDefinition("Run", ModbusRegion.Coils, 0, ModbusDataType.Bool));
        await drv.InitializeAsync("{}", CancellationToken.None);

        var builder = new RecordingBuilder();
        await drv.DiscoverAsync(builder, CancellationToken.None);

        builder.Folders.Count.ShouldBe(1);
        builder.Folders[0].BrowseName.ShouldBe("Modbus");
        builder.Variables.Count.ShouldBe(3);
        builder.Variables.ShouldContain(v => v.BrowseName == "Level" && v.Info.DriverDataType == DriverDataType.Int32);
        builder.Variables.ShouldContain(v => v.BrowseName == "Temp" && v.Info.DriverDataType == DriverDataType.Float32);
        builder.Variables.ShouldContain(v => v.BrowseName == "Run" && v.Info.DriverDataType == DriverDataType.Boolean);
    }

    /// <summary>Verifies that Discover propagates WriteIdempotent from tag to attribute info.</summary>
    [Fact]
    public async Task Discover_propagates_WriteIdempotent_from_tag_to_attribute_info()
    {
        var (drv, _) = NewDriver(
            new ModbusTagDefinition("SetPoint", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Float32, WriteIdempotent: true),
            new ModbusTagDefinition("PulseCoil", ModbusRegion.Coils, 0, ModbusDataType.Bool));
        await drv.InitializeAsync("{}", CancellationToken.None);

        var builder = new RecordingBuilder();
        await drv.DiscoverAsync(builder, CancellationToken.None);

        var setPoint = builder.Variables.Single(v => v.BrowseName == "SetPoint");
        var pulse = builder.Variables.Single(v => v.BrowseName == "PulseCoil");
        setPoint.Info.WriteIdempotent.ShouldBeTrue();
        pulse.Info.WriteIdempotent.ShouldBeFalse("default is opt-in per decision #44");
    }

    // --- helpers ---

    /// <summary>Records discovered address space structure for testing.</summary>
    private sealed class RecordingBuilder : IAddressSpaceBuilder
    {
        /// <summary>Gets the list of discovered folders.</summary>
        public List<(string BrowseName, string DisplayName)> Folders { get; } = new();

        /// <summary>Gets the list of discovered variables.</summary>
        public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();

        /// <summary>Records a folder in the address space.</summary>
        /// <param name="browseName">Folder browse name.</param>
        /// <param name="displayName">Folder display name.</param>
        public IAddressSpaceBuilder Folder(string browseName, string displayName)
        { Folders.Add((browseName, displayName)); return this; }

        /// <summary>Records a variable in the address space.</summary>
        /// <param name="browseName">Variable browse name.</param>
        /// <param name="displayName">Variable display name.</param>
        /// <param name="info">Driver attribute information.</param>
        public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
        { Variables.Add((browseName, info)); return new Handle(info.FullName); }

        /// <summary>Adds a property (no-op for recording).</summary>
        /// <param name="_">Property name (unused).</param>
        /// <param name="__">Property data type (unused).</param>
        /// <param name="___">Property value (unused).</param>
        public void AddProperty(string _, DriverDataType __, object? ___) { }

        /// <summary>Handle to a discovered variable.</summary>
        private sealed class Handle(string fullRef) : IVariableHandle
        {
            /// <summary>Gets the full reference name.</summary>
            public string FullReference => fullRef;

            /// <summary>Marks this variable as an alarm condition.</summary>
            /// <param name="info">Alarm condition information.</param>
            public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
        }

        /// <summary>No-op alarm condition sink for testing.</summary>
        private sealed class NullSink : IAlarmConditionSink
        {
            /// <summary>Handles alarm transitions (no-op).</summary>
            /// <param name="args">Alarm event arguments.</param>
            public void OnTransition(AlarmEventArgs args) { }
        }
    }
}
