using System.Text.Json;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;

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

/// <summary>
///     Regression coverage for the remaining (Low/Medium) code-review findings:
///     Driver.TwinCAT-004 (IEC time-type doc-comment accuracy), -006 (ResolveHost
///     sentinel for no-devices fallback), -014 (NotificationMaxDelayMs config knob
///     and ProbeOptions.Timeout wiring), -015 (Dispose runs a true synchronous
///     teardown, no sync-over-async), -016 (gap-fill tests: Structure-tag rejection,
///     concurrent probe + read race).
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATLowFindingsRegressionTests
{
    private const string DeviceA = "ads://5.23.91.23.1.1:851";

    // ---- Driver.TwinCAT-004 — TIME/DATE/DT/TOD surface unchanged but comments corrected ----

    /// <summary>Verifies that IEC time types map to uint32 raw counters.</summary>
    [Fact]
    public void Iec_time_types_map_to_uint32_raw_counter()
    {
        // Documents the contract called out in the corrected comments: TIME / DATE / DT / TOD
        // surface as their raw UDINT counter (32-bit unsigned), not as decoded DateTime/TimeSpan.
        // The next implementer who wants to decode them needs to see this mapping is intentional.
        TwinCATDataType.Time.ToDriverDataType().ShouldBe(DriverDataType.UInt32);
        TwinCATDataType.Date.ToDriverDataType().ShouldBe(DriverDataType.UInt32);
        TwinCATDataType.DateTime.ToDriverDataType().ShouldBe(DriverDataType.UInt32);
        TwinCATDataType.TimeOfDay.ToDriverDataType().ShouldBe(DriverDataType.UInt32);
    }

    // ---- Driver.TwinCAT-006 — ResolveHost sentinel when no devices are configured ----

    /// <summary>Verifies that ResolveHost returns an unresolved sentinel when no devices are configured.</summary>
    [Fact]
    public async Task ResolveHost_returns_unresolved_sentinel_when_no_devices()
    {
        // DriverInstanceId is a logical config-DB key, not a host address; consumers expect a
        // host key that correlates with GetHostStatuses(). When there are no devices and the
        // reference is unknown, ResolveHost must return the documented unresolved sentinel
        // (empty string), not the driver-instance ID.
        var drv = new TwinCATDriver(new TwinCATDriverOptions(), "drv-1");
        await drv.InitializeAsync("{}", CancellationToken.None);

        drv.ResolveHost("anything").ShouldBe(string.Empty);
    }

    /// <summary>Verifies that the unresolved sentinel does not match any GetHostStatuses entry.</summary>
    [Fact]
    public async Task ResolveHost_unresolved_sentinel_matches_no_GetHostStatuses_entry()
    {
        // Documents the contract: the sentinel should never match a real connectivity-status row.
        var drv = new TwinCATDriver(new TwinCATDriverOptions(), "drv-1");
        await drv.InitializeAsync("{}", CancellationToken.None);

        var sentinel = drv.ResolveHost("anything");
        drv.GetHostStatuses().ShouldNotContain(s => s.HostName == sentinel);
    }

    // ---- Driver.TwinCAT-014 — config surface knobs are honoured ----

    /// <summary>Verifies that ProbeOptions.Timeout is applied to probe calls.</summary>
    [Fact]
    public async Task ProbeOptions_Timeout_is_applied_to_probe_calls()
    {
        // The previous implementation declared a Timeout field but never read it — the probe
        // path connected with _options.Timeout. The probe must use its own configured timeout.
        var observed = new List<TimeSpan>();
        var factory = new FakeTwinCATClientFactory
        {
            Customise = () => new ProbeTimeoutCapturingFake(observed),
        };
        var drv = new TwinCATDriver(new TwinCATDriverOptions
        {
            Devices = [new TwinCATDeviceOptions(DeviceA)],
            Probe = new TwinCATProbeOptions
            {
                Enabled = true,
                Interval = TimeSpan.FromMilliseconds(100),
                Timeout = TimeSpan.FromMilliseconds(750), // distinct from the connect timeout
            },
            Timeout = TimeSpan.FromMilliseconds(2_000),
        }, "drv-1", factory);

        await drv.InitializeAsync("{}", CancellationToken.None);
        await WaitForAsync(() => observed.Count >= 1, TimeSpan.FromSeconds(2));
        await drv.ShutdownAsync(CancellationToken.None);

        observed.ShouldContain(TimeSpan.FromMilliseconds(750));
    }

    /// <summary>Verifies that NotificationMaxDelayMs is exposed on driver options.</summary>
    [Fact]
    public void NotificationMaxDelayMs_is_exposed_on_driver_options()
    {
        // The driver-spec lists NotificationMaxDelayMs as a per-device knob; the implementation
        // previously hard-coded 0 in NotificationSettings. Expose a configurable field so
        // operators can batch low-priority notifications.
        var options = new TwinCATDriverOptions { NotificationMaxDelayMs = 200 };
        options.NotificationMaxDelayMs.ShouldBe(200);
    }

    /// <summary>Verifies that NotificationMaxDelayMs parses from driver configuration JSON.</summary>
    [Fact]
    public void NotificationMaxDelayMs_parses_from_driver_config_json()
    {
        var json = JsonSerializer.Serialize(new
        {
            devices = new[] { new { hostAddress = DeviceA } },
            notificationMaxDelayMs = 150,
        });
        var parsed = TwinCATDriverFactoryExtensions.ParseOptionsForTests(json, "drv-1");
        parsed.NotificationMaxDelayMs.ShouldBe(150);
    }

    // ---- Driver.TwinCAT-015 — Dispose runs a true synchronous teardown ----

    /// <summary>Verifies that Dispose does not block on async operations in the default synchronization context.</summary>
    [Fact]
    public void Dispose_does_not_block_on_async_in_default_synchronization_context()
    {
        // Sync-over-async on a single-threaded sync context (like the OPC UA stack thread) can
        // deadlock. Dispose() must complete without scheduling continuations through a captured
        // sync context. We verify by running Dispose() inside a SynchronizationContext that
        // would deadlock a sync-over-async teardown.
        var drv = new TwinCATDriver(new TwinCATDriverOptions
        {
            Devices = [new TwinCATDeviceOptions(DeviceA)],
            Probe = new TwinCATProbeOptions { Enabled = false },
        }, "drv-1");
        drv.InitializeAsync("{}", CancellationToken.None).GetAwaiter().GetResult();

        var ctx = new SingleThreadedSyncContext();
        var prev = SynchronizationContext.Current;
        Exception? captured = null;
        try
        {
            SynchronizationContext.SetSynchronizationContext(ctx);
            // If Dispose schedules its continuations through the captured context (sync-over-
            // async pattern), and the context is single-threaded with nothing pumping, this
            // will hang. We give it 5 seconds — well above any reasonable sync teardown.
            var thread = new Thread(() =>
            {
                try
                {
                    SynchronizationContext.SetSynchronizationContext(ctx);
                    drv.Dispose();
                }
                catch (Exception ex) { captured = ex; }
            }) { IsBackground = true };
            thread.Start();
            thread.Join(TimeSpan.FromSeconds(5)).ShouldBeTrue(
                "Dispose() did not complete within 5s — likely sync-over-async deadlock " +
                "(Driver.TwinCAT-015).");
        }
        finally
        {
            SynchronizationContext.SetSynchronizationContext(prev);
        }
        captured.ShouldBeNull();
    }

    /// <summary>
    ///     Single-threaded sync context that posts continuations to an internal queue but never
    ///     pumps them. Any sync-over-async code that captures this context and waits for a
    ///     continuation will deadlock — exactly the OPC UA stack thread scenario.
    /// </summary>
    private sealed class SingleThreadedSyncContext : SynchronizationContext
    {
        private readonly System.Collections.Concurrent.ConcurrentQueue<(SendOrPostCallback cb, object? state)> _queue = new();
        /// <inheritdoc />
        public override void Post(SendOrPostCallback d, object? state) => _queue.Enqueue((d, state));

        /// <inheritdoc />
        public override void Send(SendOrPostCallback d, object? state) => d(state);
    }

    // ---- Driver.TwinCAT-016 — gap-fill tests for previously closed findings ----

    /// <summary>Verifies that a Structure-typed raw tag is rejected by the tag-definition factory.</summary>
    [Fact]
    public void Structure_typed_raw_tag_is_rejected_by_the_factory()
    {
        // Driver.TwinCAT-003 — v3 the mapper is the sole tag path. A Structure tag must NOT map to a
        // definition (returns false ⇒ the driver skips it ⇒ BadNodeIdUnknown) rather than reading as a
        // garbage int blob or failing late on a write.
        var blob = """{"symbolPath":"MAIN.fbInstance","dataType":"Structure"}""";
        TwinCATTagDefinitionFactory.FromTagConfig(blob, "equip/Udt1", out _).ShouldBeFalse();
    }

    /// <summary>Verifies that the probe loop and read operations share one client per device.</summary>
    [Fact]
    public async Task Probe_loop_and_read_share_one_client_per_device()
    {
        // Driver.TwinCAT-007 / -009 — gap-fill: race the probe loop against concurrent reads on
        // the same device. The per-device gate must serialize connect; the probe-task await on
        // ShutdownAsync must let the loop exit cleanly. Without these, the test trips a leaked
        // client or a disposal race.
        var factory = new FakeTwinCATClientFactory
        {
            Customise = () => new FakeTwinCATClient { Values = { ["GVL.X"] = 1 }, ProbeResult = true },
        };
        var drv = new TwinCATDriver(new TwinCATDriverOptions
        {
            Devices = [new TwinCATDeviceOptions(DeviceA)],
            RawTags = TwinCATRawTags.Entries([new TwinCATTagDefinition("X", DeviceA, "GVL.X", TwinCATDataType.DInt)]),
            Probe = new TwinCATProbeOptions
            {
                Enabled = true,
                Interval = TimeSpan.FromMilliseconds(20),
                Timeout = TimeSpan.FromMilliseconds(50),
            },
        }, "drv-1", factory);
        await drv.InitializeAsync("{}", CancellationToken.None);

        // Race 64 readers against the probe loop for ~500ms.
        var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
        var work = Enumerable.Range(0, 64).Select(_ => Task.Run(async () =>
        {
            while (!cts.IsCancellationRequested)
            {
                try { await drv.ReadAsync(["X"], CancellationToken.None); }
                catch { /* shutdown-races on the very last call are ok */ }
            }
        })).ToArray();
        await Task.WhenAll(work);
        await drv.ShutdownAsync(CancellationToken.None);

        // One client total. If the gate is broken, concurrent connects leak additional clients.
        factory.Clients.Count.ShouldBe(1);
        factory.Clients[0].ConnectCount.ShouldBe(1);
    }

    private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
    {
        var deadline = DateTime.UtcNow + timeout;
        while (!condition() && DateTime.UtcNow < deadline)
            await Task.Delay(20);
    }

    /// <summary>Captures the timeout argument from ProbeAsync invocations (via the connect path).</summary>
    private sealed class ProbeTimeoutCapturingFake : FakeTwinCATClient
    {
        private readonly List<TimeSpan> _observed;

        /// <summary>Initializes a new instance of the ProbeTimeoutCapturingFake class.</summary>
        /// <param name="observed">The list to store observed timeout values.</param>
        public ProbeTimeoutCapturingFake(List<TimeSpan> observed) { _observed = observed; }

        /// <inheritdoc />
        public override Task ConnectAsync(TwinCATAmsAddress address, TimeSpan timeout, CancellationToken ct)
        {
            // The driver calls EnsureConnectedAsync with the probe timeout for probe-initiated
            // connects. The probe timeout is distinct from the driver-level Timeout; we record
            // it on the first connect.
            lock (_observed) _observed.Add(timeout);
            return base.ConnectAsync(address, timeout, ct);
        }
    }
}
