using System.Diagnostics;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;

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

/// <summary>
///     Coverage for the FOCAS data-plane fix (2026-06-25 equipment-tag investigation): all wire I/O
///     on a device's single FOCAS/2 socket must be serialized (request→response cannot interleave)
///     and every steady-state read/write must be time-bounded so a stalled CNC read surfaces as a
///     recoverable error instead of hanging forever at BadWaitingForInitialData. See
///     <c>docs/plans/2026-06-25-otopcua-equipment-dataplane-investigation.md</c>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasIoSerializationTests
{
    private static readonly FocasAddress Macro500 = new(FocasAreaKind.Macro, null, 500, null);

    // ---- SynchronizedFocasClient: serialization ----

    [Fact]
    public async Task Concurrent_reads_are_serialized_onto_the_inner_client()
    {
        var inner = new RecordingClient { ReadDelay = TimeSpan.FromMilliseconds(20) };
        await using var _ = NoopDispose(inner);
        var client = new SynchronizedFocasClient(inner, TimeSpan.FromSeconds(5));

        var reads = Enumerable.Range(0, 8)
            .Select(_ => client.ReadAsync(Macro500, FocasDataType.Float64, CancellationToken.None));
        await Task.WhenAll(reads);

        inner.MaxConcurrency.ShouldBe(1); // never more than one wire op on the socket at a time
        inner.ReadCount.ShouldBe(8);
    }

    // ---- SynchronizedFocasClient: per-call timeout ----

    [Fact]
    public async Task A_hung_read_is_bounded_by_the_call_timeout()
    {
        var inner = new RecordingClient { BlockReadUntilCancelled = true };
        var client = new SynchronizedFocasClient(inner, TimeSpan.FromMilliseconds(100));

        var sw = Stopwatch.StartNew();
        await Should.ThrowAsync<OperationCanceledException>(
            () => client.ReadAsync(Macro500, FocasDataType.Float64, CancellationToken.None));
        sw.Stop();

        sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(2)); // bounded, not the indefinite OS TCP wait
    }

    [Fact]
    public async Task A_hung_read_does_not_hold_the_socket_for_the_next_call()
    {
        // The gate must be released when a bounded call times out, otherwise one stall would wedge
        // every subsequent op on the device. Read #1 hangs (times out); read #2 must still proceed.
        var inner = new TimeoutThenServeClient { FirstCallBlocks = true };
        var client = new SynchronizedFocasClient(inner, TimeSpan.FromMilliseconds(100));

        await Should.ThrowAsync<OperationCanceledException>(
            () => client.ReadAsync(Macro500, FocasDataType.Float64, CancellationToken.None));

        var (value, status) = await client.ReadAsync(Macro500, FocasDataType.Float64, CancellationToken.None);
        status.ShouldBe(FocasStatusMapper.Good);
        value.ShouldBe(42);
    }

    [Fact]
    public async Task Probe_is_not_bounded_by_the_call_timeout()
    {
        // Connect/Probe carry their own budgets; the decorator must not shrink them to its read budget.
        var inner = new RecordingClient { ProbeDelay = TimeSpan.FromMilliseconds(200) };
        var client = new SynchronizedFocasClient(inner, TimeSpan.FromMilliseconds(50));

        var result = await client.ProbeAsync(CancellationToken.None);

        result.ShouldBeTrue();
    }

    [Fact]
    public async Task Zero_call_timeout_disables_the_per_call_bound()
    {
        var inner = new RecordingClient { ReadDelay = TimeSpan.FromMilliseconds(120) };
        var client = new SynchronizedFocasClient(inner, TimeSpan.Zero);

        var (value, status) = await client.ReadAsync(Macro500, FocasDataType.Float64, CancellationToken.None);

        status.ShouldBe(FocasStatusMapper.Good);
        value.ShouldBe(42);
    }

    [Fact]
    public void Dispose_disposes_the_inner_client()
    {
        var inner = new RecordingClient();
        var client = new SynchronizedFocasClient(inner, TimeSpan.FromSeconds(1));

        client.Dispose();

        inner.DisposeCount.ShouldBe(1);
    }

    // ---- Driver level: a timed-out read overwrites the seed with a recoverable status ----

    [Fact]
    public async Task Driver_read_that_times_out_returns_BadCommunicationError_not_a_hang()
    {
        var factory = new FakeFocasClientFactory { Customise = () => new RecordingClient { BlockReadUntilCancelled = true } };
        var drv = new FocasDriver(new FocasDriverOptions
        {
            Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
            RawTags = [FocasRawTags.Entry(new FocasTagDefinition("CustomVar", "focas://10.0.0.5:8193", "MACRO:500", FocasDataType.Float64))],
            Probe = new FocasProbeOptions { Enabled = false },
            Timeout = TimeSpan.FromMilliseconds(150),
        }, "drv-1", factory);
        await drv.InitializeAsync("{}", CancellationToken.None);

        var sw = Stopwatch.StartNew();
        var snap = (await drv.ReadAsync(["CustomVar"], CancellationToken.None)).Single();
        sw.Stop();

        snap.StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
        sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(2)); // bounded by Timeout, not hung
    }

    [Fact]
    public async Task Driver_read_does_not_propagate_a_call_timeout_as_cancellation()
    {
        // The per-call timeout must NOT bubble out of ReadAsync as OperationCanceledException — that
        // would abort the whole poll batch. It must be caught and turned into a per-tag Bad status.
        var factory = new FakeFocasClientFactory { Customise = () => new RecordingClient { BlockReadUntilCancelled = true } };
        var drv = new FocasDriver(new FocasDriverOptions
        {
            Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
            RawTags = [FocasRawTags.Entry(new FocasTagDefinition("CustomVar", "focas://10.0.0.5:8193", "MACRO:500", FocasDataType.Float64))],
            Probe = new FocasProbeOptions { Enabled = false },
            Timeout = TimeSpan.FromMilliseconds(120),
        }, "drv-1", factory);
        await drv.InitializeAsync("{}", CancellationToken.None);

        // Should complete (not throw) with a Bad snapshot, even though the caller's token is never cancelled.
        var snaps = await drv.ReadAsync(["CustomVar"], CancellationToken.None);
        snaps.Single().StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
    }

    private static DisposeGuard NoopDispose(IDisposable d) => new(d);

    private sealed class DisposeGuard(IDisposable inner) : IAsyncDisposable
    {
        public ValueTask DisposeAsync() { inner.Dispose(); return ValueTask.CompletedTask; }
    }

    /// <summary>Fake that records concurrency + optionally delays/blocks reads and probes.</summary>
    private class RecordingClient : FakeFocasClient
    {
        private int _current;
        public int MaxConcurrency;
        public int ReadCount;
        public TimeSpan ReadDelay = TimeSpan.Zero;
        public bool BlockReadUntilCancelled;
        public TimeSpan ProbeDelay = TimeSpan.Zero;

        public override async Task<(object? value, uint status)> ReadAsync(
            FocasAddress address, FocasDataType type, CancellationToken ct)
        {
            Interlocked.Increment(ref ReadCount);
            var observed = Interlocked.Increment(ref _current);
            InterlockedMax(ref MaxConcurrency, observed);
            try
            {
                if (BlockReadUntilCancelled) await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
                else if (ReadDelay > TimeSpan.Zero) await Task.Delay(ReadDelay, ct).ConfigureAwait(false);
                return ((object?)42, FocasStatusMapper.Good);
            }
            finally { Interlocked.Decrement(ref _current); }
        }

        public override async Task<bool> ProbeAsync(CancellationToken ct)
        {
            if (ProbeDelay > TimeSpan.Zero) await Task.Delay(ProbeDelay, ct).ConfigureAwait(false);
            return true;
        }

        private static void InterlockedMax(ref int target, int value)
        {
            int seen;
            do { seen = Volatile.Read(ref target); if (value <= seen) return; }
            while (Interlocked.CompareExchange(ref target, value, seen) != seen);
        }
    }

    /// <summary>First read blocks until cancelled; subsequent reads serve a Good value immediately.</summary>
    private sealed class TimeoutThenServeClient : FakeFocasClient
    {
        public bool FirstCallBlocks;
        private int _calls;

        public override async Task<(object? value, uint status)> ReadAsync(
            FocasAddress address, FocasDataType type, CancellationToken ct)
        {
            var n = Interlocked.Increment(ref _calls);
            if (n == 1 && FirstCallBlocks) await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
            return ((object?)42, FocasStatusMapper.Good);
        }
    }
}
