using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;

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

/// <summary>
///     #143 block-read coalescing: with MaxReadGap > 0 the driver merges nearby tags into a
///     single FC03/FC04 read. Coverage focuses on the planner output (PDU count + quantity)
///     rather than wire bytes — those are tested by ModbusDriverTests.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusCoalescingTests
{
    private sealed class CountingTransport : IModbusTransport
    {
        public readonly List<(byte Unit, byte Fc, ushort Address, ushort Quantity)> Reads = new();
        /// <summary>Establishes a connection asynchronously.</summary>
        /// <param name="ct">Cancellation token for the operation.</param>
        public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
        /// <summary>Sends a Modbus PDU and receives the response.</summary>
        /// <param name="unitId">The Modbus unit identifier.</param>
        /// <param name="pdu">The Protocol Data Unit to send.</param>
        /// <param name="ct">Cancellation token for the operation.</param>
        public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
        {
            var addr = (ushort)((pdu[1] << 8) | pdu[2]);
            var qty = (ushort)((pdu[3] << 8) | pdu[4]);
            if (pdu[0] is 0x03 or 0x04) Reads.Add((unitId, pdu[0], addr, qty));
            switch (pdu[0])
            {
                case 0x03: case 0x04:
                {
                    var resp = new byte[2 + qty * 2];
                    resp[0] = pdu[0]; resp[1] = (byte)(qty * 2);
                    return Task.FromResult(resp);
                }
                default: return Task.FromResult(new byte[] { pdu[0], 0, 0 });
            }
        }
        /// <summary>Disposes the transport asynchronously.</summary>
        public ValueTask DisposeAsync() => ValueTask.CompletedTask;
    }

    /// <summary>Verifies that MaxReadGap=0 defaults to per-tag reads without coalescing.</summary>
    [Fact]
    public async Task MaxReadGap_Zero_Defaults_To_Per_Tag_Reads()
    {
        var fake = new CountingTransport();
        var t1 = new ModbusTagDefinition("T1", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
        var t2 = new ModbusTagDefinition("T2", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16);
        var opts = new ModbusDriverOptions { Host = "f", RawTags = ModbusRawTags.Entries([t1, t2]), MaxReadGap = 0,
            Probe = new ModbusProbeOptions { Enabled = false } };
        var drv = new ModbusDriver(opts, "m1", _ => fake);
        await drv.InitializeAsync("{}", CancellationToken.None);

        await drv.ReadAsync(["T1", "T2"], CancellationToken.None);

        // With coalescing off, expect 2 separate FC03 reads.
        var fc03Reads = fake.Reads.Where(r => r.Fc == 0x03).ToList();
        fc03Reads.Count.ShouldBe(2);
    }

    /// <summary>Verifies that MaxReadGap bridges adjacent tags into a single read.</summary>
    [Fact]
    public async Task MaxReadGap_Bridges_Two_Adjacent_Tags_Into_One_Read()
    {
        var fake = new CountingTransport();
        // Three tags within 5 registers: T1@100, T2@102, T3@104. Gaps: 1, 1. MaxReadGap=2 → 1 block.
        var t1 = new ModbusTagDefinition("T1", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
        var t2 = new ModbusTagDefinition("T2", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16);
        var t3 = new ModbusTagDefinition("T3", ModbusRegion.HoldingRegisters, 104, ModbusDataType.Int16);
        var opts = new ModbusDriverOptions { Host = "f", RawTags = ModbusRawTags.Entries([t1, t2, t3]), MaxReadGap = 2,
            Probe = new ModbusProbeOptions { Enabled = false } };
        var drv = new ModbusDriver(opts, "m1", _ => fake);
        await drv.InitializeAsync("{}", CancellationToken.None);

        await drv.ReadAsync(["T1", "T2", "T3"], CancellationToken.None);

        var fc03Reads = fake.Reads.Where(r => r.Fc == 0x03).ToList();
        fc03Reads.Count.ShouldBe(1);
        fc03Reads[0].Address.ShouldBe((ushort)100);
        fc03Reads[0].Quantity.ShouldBe((ushort)5);  // 100..104
    }

    /// <summary>Verifies that MaxReadGap splits blocks when gaps exceed threshold.</summary>
    [Fact]
    public async Task MaxReadGap_Splits_When_Gap_Exceeds_Threshold()
    {
        var fake = new CountingTransport();
        // T1@100, T2@102 (gap 1, joins block), T3@200 (gap 97 → exceeds gap=10 → second block).
        var t1 = new ModbusTagDefinition("T1", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
        var t2 = new ModbusTagDefinition("T2", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16);
        var t3 = new ModbusTagDefinition("T3", ModbusRegion.HoldingRegisters, 200, ModbusDataType.Int16);
        var opts = new ModbusDriverOptions { Host = "f", RawTags = ModbusRawTags.Entries([t1, t2, t3]), MaxReadGap = 10,
            Probe = new ModbusProbeOptions { Enabled = false } };
        var drv = new ModbusDriver(opts, "m1", _ => fake);
        await drv.InitializeAsync("{}", CancellationToken.None);

        await drv.ReadAsync(["T1", "T2", "T3"], CancellationToken.None);

        var fc03Reads = fake.Reads.Where(r => r.Fc == 0x03).ToList();
        fc03Reads.Count.ShouldBe(2);    // T1+T2 coalesced; T3 alone
    }

    /// <summary>Verifies that tags with CoalesceProhibited are read separately.</summary>
    [Fact]
    public async Task CoalesceProhibited_Tag_Reads_Alone()
    {
        var fake = new CountingTransport();
        var t1 = new ModbusTagDefinition("T1", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
        var t2 = new ModbusTagDefinition("T2", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16, CoalesceProhibited: true);
        var t3 = new ModbusTagDefinition("T3", ModbusRegion.HoldingRegisters, 104, ModbusDataType.Int16);
        var opts = new ModbusDriverOptions { Host = "f", RawTags = ModbusRawTags.Entries([t1, t2, t3]), MaxReadGap = 10,
            Probe = new ModbusProbeOptions { Enabled = false } };
        var drv = new ModbusDriver(opts, "m1", _ => fake);
        await drv.InitializeAsync("{}", CancellationToken.None);

        await drv.ReadAsync(["T1", "T2", "T3"], CancellationToken.None);

        var fc03Reads = fake.Reads.Where(r => r.Fc == 0x03).ToList();
        // T2 read alone (CoalesceProhibited). T1 and T3 coalesce (gap = 3 within MaxReadGap=10).
        // Expect 2 reads total.
        fc03Reads.Count.ShouldBe(2);
    }

    /// <summary>Verifies that coalescing does not cross unit ID boundaries.</summary>
    [Fact]
    public async Task Coalescing_Does_Not_Cross_UnitId_Boundaries()
    {
        var fake = new CountingTransport();
        // Same Region + adjacent addresses but different UnitIds → must NOT coalesce.
        var t1 = new ModbusTagDefinition("T1", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16, UnitId: 1);
        var t2 = new ModbusTagDefinition("T2", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16, UnitId: 2);
        var opts = new ModbusDriverOptions { Host = "f", RawTags = ModbusRawTags.Entries([t1, t2]), MaxReadGap = 100,
            Probe = new ModbusProbeOptions { Enabled = false } };
        var drv = new ModbusDriver(opts, "m1", _ => fake);
        await drv.InitializeAsync("{}", CancellationToken.None);

        await drv.ReadAsync(["T1", "T2"], CancellationToken.None);

        var fc03Reads = fake.Reads.Where(r => r.Fc == 0x03).ToList();
        fc03Reads.Count.ShouldBe(2);
        fc03Reads.Select(r => r.Unit).Distinct().Count().ShouldBe(2);
    }

    /// <summary>Verifies that coalescing splits blocks exceeding MaxRegistersPerRead.</summary>
    [Fact]
    public async Task Coalescing_Splits_Block_That_Exceeds_MaxRegistersPerRead()
    {
        var fake = new CountingTransport();
        // T1@0, T2@200 with MaxReadGap=300 would naturally form one block of 201 registers,
        // but MaxRegistersPerRead=125 caps it. The planner should NOT coalesce because the
        // resulting span exceeds the cap — it falls back to two separate reads.
        var t1 = new ModbusTagDefinition("T1", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16);
        var t2 = new ModbusTagDefinition("T2", ModbusRegion.HoldingRegisters, 200, ModbusDataType.Int16);
        var opts = new ModbusDriverOptions { Host = "f", RawTags = ModbusRawTags.Entries([t1, t2]), MaxReadGap = 300,
            MaxRegistersPerRead = 125, Probe = new ModbusProbeOptions { Enabled = false } };
        var drv = new ModbusDriver(opts, "m1", _ => fake);
        await drv.InitializeAsync("{}", CancellationToken.None);

        await drv.ReadAsync(["T1", "T2"], CancellationToken.None);

        var fc03Reads = fake.Reads.Where(r => r.Fc == 0x03).ToList();
        fc03Reads.Count.ShouldBe(2);
    }

    /// <summary>Verifies that coalesced reads surface each tag value independently.</summary>
    [Fact]
    public async Task Coalesced_Read_Surfaces_Each_Tag_Value_Independently()
    {
        // Sanity check: after coalescing the per-tag values must still be correct (no
        // index-shift bugs in the slice math).
        var fake = new CountingTransport();
        var t1 = new ModbusTagDefinition("T1", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
        var t2 = new ModbusTagDefinition("T2", ModbusRegion.HoldingRegisters, 101, ModbusDataType.Int16);
        var opts = new ModbusDriverOptions { Host = "f", RawTags = ModbusRawTags.Entries([t1, t2]), MaxReadGap = 5,
            Probe = new ModbusProbeOptions { Enabled = false } };
        var drv = new ModbusDriver(opts, "m1", _ => fake);
        await drv.InitializeAsync("{}", CancellationToken.None);

        var values = await drv.ReadAsync(["T1", "T2"], CancellationToken.None);

        values.Count.ShouldBe(2);
        values[0].StatusCode.ShouldBe(0u);
        values[1].StatusCode.ShouldBe(0u);
        // The fake returns zeros for our values; the assertion is on quality + that the slice
        // didn't mis-index (a bug there would surface as IndexOutOfRange / wrong type).
    }
}
