using System.Diagnostics;
using Shouldly;
using Xunit;

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

/// <summary>
///     Unit tests for <see cref="S7OperationDeadline"/> — the wall-clock ceiling that bounds every
///     S7 async wire op (R2-01 read-leg gate). Async S7.Net reads/writes ignore the socket
///     ReadTimeout/WriteTimeout, so a frozen-but-established peer would otherwise block for minutes;
///     these pin the deadline behaviour without a live PLC.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7OperationDeadlineTests
{
    private static readonly TimeSpan ShortTimeout = TimeSpan.FromMilliseconds(150);

    /// <summary>An operation that completes within the deadline returns its value untouched.</summary>
    [Fact]
    public async Task Completes_within_deadline_returns_value()
    {
        var result = await S7OperationDeadline.RunAsync(
            _ => Task.FromResult(42), ShortTimeout, "read", TestContext.Current.CancellationToken);
        result.ShouldBe(42);
    }

    /// <summary>
    ///     An operation that ignores its token and never completes is cut at the deadline and
    ///     surfaces a <see cref="TimeoutException"/> — the frozen-established-peer shape. Uses a bare
    ///     <see cref="TaskCompletionSource"/> that never completes, so the token is genuinely ignored.
    /// </summary>
    [Fact]
    public async Task Token_ignoring_hang_times_out()
    {
        var neverCompletes = new TaskCompletionSource<int>().Task;
        var sw = Stopwatch.StartNew();

        var ex = await Should.ThrowAsync<TimeoutException>(async () =>
            await S7OperationDeadline.RunAsync(
                _ => neverCompletes, ShortTimeout, "read of 'DB1.DBW0'", TestContext.Current.CancellationToken));

        sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(5));
        ex.Message.ShouldContain("did not complete within 150 ms");
    }

    /// <summary>
    ///     An operation that honours its token (the well-behaved async-read case) is cancelled at the
    ///     deadline; the resulting cancellation is normalised to a <see cref="TimeoutException"/>,
    ///     NOT surfaced as an <see cref="OperationCanceledException"/>.
    /// </summary>
    [Fact]
    public async Task Token_honouring_hang_times_out_as_timeout_not_cancel()
    {
        await Should.ThrowAsync<TimeoutException>(async () =>
            await S7OperationDeadline.RunAsync(
                async token =>
                {
                    await Task.Delay(Timeout.Infinite, token);
                    return 0;
                },
                ShortTimeout, "read", TestContext.Current.CancellationToken));
    }

    /// <summary>
    ///     Caller cancellation (as opposed to the deadline firing) propagates as an
    ///     <see cref="OperationCanceledException"/> — the deadline wrapper must not swallow genuine
    ///     cancellation into a timeout.
    /// </summary>
    [Fact]
    public async Task Caller_cancellation_propagates_as_operation_cancelled()
    {
        using var cts = new CancellationTokenSource();
        cts.CancelAfter(50);

        // A long deadline so the CALLER token fires first, not the internal deadline.
        await Should.ThrowAsync<OperationCanceledException>(async () =>
            await S7OperationDeadline.RunAsync(
                async token =>
                {
                    await Task.Delay(Timeout.Infinite, token);
                    return 0;
                },
                TimeSpan.FromSeconds(30), "read", cts.Token));
    }

    /// <summary>The result-less overload also bounds a hang and surfaces a <see cref="TimeoutException"/>.</summary>
    [Fact]
    public async Task Resultless_overload_times_out()
    {
        var neverCompletes = new TaskCompletionSource().Task;
        await Should.ThrowAsync<TimeoutException>(async () =>
            await S7OperationDeadline.RunAsync(
                _ => neverCompletes, ShortTimeout, "write of 'DB1.DBW0'", TestContext.Current.CancellationToken));
    }
}
