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

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

/// <summary>
///     Regression tests for the remaining code-review findings closed against the S7 driver:
///     Driver.S7-003 (Read/WriteAsync null-arg validation), Driver.S7-009 (poll-loop health
///     update + backoff), Driver.S7-010 (Dispose without sync-over-async), and the Phase 4d
///     guard-(b) (wide/structured types must be byte-addressed). The wide types are no longer
///     rejected wholesale at init — the 8-byte numerics round-trip through the byte-buffer codec
///     (see <see cref="S7ScalarBlockTests"/>); only a wide type at a non-byte address is rejected.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7DriverCodeReviewFixTests2
{
    // ── Driver.S7-003 — Read/WriteAsync must throw ArgumentNullException, not NRE ─────────

    /// <summary>Verifies that ReadAsync throws ArgumentNullException for null references.</summary>
    [Fact]
    public async Task ReadAsync_with_null_fullReferences_throws_ArgumentNullException()
    {
        // The driver must validate its inputs consistently with DiscoverAsync (which already
        // uses ArgumentNullException.ThrowIfNull). A NullReferenceException escaping the entry
        // point bypasses the gate and gives the caller a non-actionable stack.
        using var drv = new S7Driver(new S7DriverOptions { Host = "192.0.2.1" }, "s7-null-read");
        await Should.ThrowAsync<ArgumentNullException>(async () =>
            await drv.ReadAsync(null!, TestContext.Current.CancellationToken));
    }

    /// <summary>Verifies that WriteAsync throws ArgumentNullException for null writes.</summary>
    [Fact]
    public async Task WriteAsync_with_null_writes_throws_ArgumentNullException()
    {
        using var drv = new S7Driver(new S7DriverOptions { Host = "192.0.2.1" }, "s7-null-write");
        await Should.ThrowAsync<ArgumentNullException>(async () =>
            await drv.WriteAsync(null!, TestContext.Current.CancellationToken));
    }

    // ── Driver.S7-009 — Poll loop must update health on sustained failure ────────────────

    /// <summary>Verifies that the poll loop degrades health when the driver is uninitialized.</summary>
    [Fact]
    public async Task PollLoop_against_uninitialized_driver_degrades_health()
    {
        // Subscribing without InitializeAsync means RequirePlc() throws on every poll tick.
        // Previously the empty catch swallowed everything and the dashboard reported Healthy
        // / Unknown indefinitely. With the fix the poll loop must surface the failure on the
        // health surface so an operator can see it (driver-stability convention).
        var opts = new S7DriverOptions
        {
            Host = "192.0.2.1",
            Probe = new S7ProbeOptions { Enabled = false },
        };
        var drv = new S7Driver(opts, "s7-poll-health");

        await drv.SubscribeAsync(["A"], TimeSpan.FromMilliseconds(50), TestContext.Current.CancellationToken);

        // Wait long enough for several poll ticks. With backoff the second tick should arrive
        // within ~100-200 ms; allow generous slack.
        for (var i = 0; i < 40 && drv.GetHealth().State is DriverState.Unknown or DriverState.Initializing; i++)
            await Task.Delay(50, TestContext.Current.CancellationToken);

        drv.GetHealth().State.ShouldBe(DriverState.Degraded,
            "sustained poll failure must surface on the health state — see Driver.S7-009");

        await drv.ShutdownAsync(CancellationToken.None);
        await drv.DisposeAsync();
    }

    /// <summary>Verifies that the poll loop applies capped backoff after consecutive failures.</summary>
    [Fact]
    public async Task PollLoop_applies_capped_backoff_after_consecutive_failures()
    {
        // After repeated poll errors the loop must back off rather than burn CPU re-polling
        // every Interval — Driver.S7-009 calls for a capped backoff. Inspect the ratio of
        // observed tick count to the floor count we would have seen WITHOUT backoff over a
        // short window. Without backoff, an Interval=50 ms loop with sub-ms ReadAsync
        // RequirePlc-throw would tick ~20 times in 1 s. With capped backoff it ticks far
        // fewer; we use a generous upper bound that still proves "something is throttling".
        var opts = new S7DriverOptions
        {
            Host = "192.0.2.1",
            Probe = new S7ProbeOptions { Enabled = false },
        };
        var drv = new S7Driver(opts, "s7-poll-backoff");

        var ticks = 0;
        drv.OnDataChange += (_, _) => Interlocked.Increment(ref ticks);

        await drv.SubscribeAsync(["A"], TimeSpan.FromMilliseconds(50), TestContext.Current.CancellationToken);

        // OnDataChange is never raised (every poll fails) so we can't count via the event.
        // Instead, time-bound the test and rely on the health-degradation test above to prove
        // the catch ran; here we just confirm the driver doesn't deadlock or spin so hot it
        // refuses to shut down. ShutdownAsync must complete within the drain window.
        await Task.Delay(500, TestContext.Current.CancellationToken);

        var sw = System.Diagnostics.Stopwatch.StartNew();
        await drv.ShutdownAsync(CancellationToken.None);
        sw.Stop();
        sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(6),
            "shutdown must complete inside the drain timeout — a runaway backoff would block it");

        await drv.DisposeAsync();
        _ = ticks; // silence unused
    }

    // ── Driver.S7-010 — Dispose() must not deadlock via sync-over-async ──────────────────

    /// <summary>Verifies that Dispose completes synchronously without sync-over-async.</summary>
    [Fact]
    public void Dispose_completes_synchronously_without_sync_over_async_round_trip()
    {
        // The sync Dispose() path must perform the teardown directly rather than blocking on
        // DisposeAsync().AsTask().GetAwaiter().GetResult(). The current sync-over-async pattern
        // is a known deadlock risk even when the wrapped Task.Run paths happen to be safe.
        // We assert by measuring Dispose() runtime on a no-subscription, no-init driver: it
        // should complete in microseconds, not the ~5 s drain window that a hung sync-over-async
        // would burn waiting on a never-completing continuation.
        var drv = new S7Driver(new S7DriverOptions { Host = "192.0.2.1" }, "s7-dispose-sync");
        var sw = System.Diagnostics.Stopwatch.StartNew();
        drv.Dispose();
        sw.Stop();
        sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(1),
            "Dispose() must teardown directly — see Driver.S7-010");
    }

    /// <summary>Verifies that Dispose is idempotent.</summary>
    [Fact]
    public void Dispose_is_idempotent()
    {
        // After the rewrite Dispose() must remain safe to call twice — disposal is
        // best-effort and the second call must not throw.
        var drv = new S7Driver(new S7DriverOptions { Host = "192.0.2.1" }, "s7-dispose-twice");
        drv.Dispose();
        Should.NotThrow(() => drv.Dispose());
    }

    // ── Phase 4d guard-(b) — wide/structured types must be byte-addressed ────────────────

    /// <summary>
    ///     Verifies the init guard rejects a wide/structured type authored at a non-byte address
    ///     (here <c>DB1.DBD0</c>, a DWord). Phase 4d wired the 8-byte numerics (Int64/UInt64/
    ///     Float64) through the byte-buffer codec — see <see cref="S7ScalarBlockTests"/> for the
    ///     round-trip coverage — but they (and the still-deferred String/DateTime) decode from a
    ///     byte-anchored block (<c>DBB</c>/<c>MB</c>/<c>IB</c>/<c>QB</c>). A non-byte suffix would
    ///     mis-frame the value, so <c>RejectUnsupportedTagConfigs</c> guard-(b) fails the config
    ///     fast at init rather than as a misleading per-read fault.
    /// </summary>
    /// <param name="dt">A wide/structured S7 data type authored at a non-byte address.</param>
    [Theory]
    [InlineData(S7DataType.Int64)]
    [InlineData(S7DataType.UInt64)]
    [InlineData(S7DataType.Float64)]
    [InlineData(S7DataType.String)]
    [InlineData(S7DataType.DateTime)]
    public async Task Initialize_rejects_wide_type_at_non_byte_address_with_NotSupportedException(S7DataType dt)
    {
        var opts = new S7DriverOptions
        {
            Host = "192.0.2.1",
            Timeout = TimeSpan.FromMilliseconds(250),
            // DB1.DBD0 is a DWord (non-byte) address — guard-(b) must fault on the address shape
            // for a wide type. (At a byte address like DB1.DBB0 the 8-byte numerics round-trip;
            // see S7ScalarBlockTests.)
            RawTags = S7RawTags.Entries(new S7TagDefinition("X", "DB1.DBD0", dt)),
        };
        using var drv = new S7Driver(opts, $"s7-wide-nonbyte-{dt}");

        var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
            await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
        ex.Message.ShouldContain(dt.ToString());

        var health = drv.GetHealth();
        health.State.ShouldBe(DriverState.Faulted);
    }

    /// <summary>Verifies that Initialize accepts implemented data types.</summary>
    /// <param name="dt">The S7 data type to test.</param>
    /// <param name="addr">The S7 address string corresponding to the data type.</param>
    [Theory]
    [InlineData(S7DataType.Bool, "DB1.DBX0.0")]
    [InlineData(S7DataType.Byte, "DB1.DBB0")]
    [InlineData(S7DataType.Int16, "DB1.DBW0")]
    [InlineData(S7DataType.UInt16, "DB1.DBW0")]
    [InlineData(S7DataType.Int32, "DB1.DBD0")]
    [InlineData(S7DataType.UInt32, "DB1.DBD0")]
    [InlineData(S7DataType.Float32, "DB1.DBD0")]
    public async Task Initialize_accepts_implemented_data_types(S7DataType dt, string addr)
    {
        // Sanity check the guard is targeted — implemented data types must still pass. The
        // TCP connect still fails (reserved host); the failure must NOT be a NotSupportedException
        // from the data-type guard.
        var opts = new S7DriverOptions
        {
            Host = "192.0.2.1",
            Timeout = TimeSpan.FromMilliseconds(250),
            RawTags = S7RawTags.Entries(new S7TagDefinition("X", addr, dt)),
        };
        using var drv = new S7Driver(opts, $"s7-good-dt-{dt}");

        var ex = await Should.ThrowAsync<Exception>(async () =>
            await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
        ex.ShouldNotBeOfType<NotSupportedException>(
            "implemented data types must pass the init guard — the failure must be the connect");
    }
}
