using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;

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

/// <summary>
///     Regression coverage for the Low-severity code-review findings:
///     <list type="bullet">
///         <item>Driver.FOCAS-008 — parsed FocasAddress is cached at init, not re-parsed per read/write</item>
///         <item>Driver.FOCAS-009 — Probe.Timeout is actually applied around ProbeAsync</item>
///         <item>Driver.FOCAS-010 — operation-mode → text mapping is consolidated</item>
///         <item>Driver.FOCAS-011 — FocasAlarmType constants are typed as short</item>
///     </list>
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasLowFindingsTests
{
    // ---- Driver.FOCAS-008 — parsed FocasAddress cached at init ----

    /// <summary>
    /// Verifies that ReadAsync uses cached FocasAddress when tag definition has a malformed address after init.
    /// </summary>
    [Fact]
    public async Task ReadAsync_uses_cached_FocasAddress_when_tag_definition_has_a_malformed_address_after_init()
    {
        // After InitializeAsync succeeds with a well-formed address, the driver must rely on the
        // cached parse — *not* re-parse `FocasTagDefinition.Address` on every read. We can prove
        // the cache is used by stuffing a malformed Address onto a tag *post-init* through the
        // internal cache surface — but that's brittle. Instead, prove no re-parse by counting:
        // we monkey-patch the FakeFocasClient.ReadAsync to capture the FocasAddress reference
        // it receives and assert it's the *same* instance across two consecutive reads.
        var captured = new List<FocasAddress>();
        var factory = new FakeFocasClientFactory
        {
            Customise = () => new CapturingFakeFocasClient(captured)
            {
                Values = { ["R100"] = (sbyte)1 },
            },
        };
        var drv = new FocasDriver(new FocasDriverOptions
        {
            Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
            RawTags = [FocasRawTags.Entry(new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))],
            Probe = new FocasProbeOptions { Enabled = false },
        }, "drv-1", factory);
        await drv.InitializeAsync("{}", CancellationToken.None);

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

        captured.Count.ShouldBe(2);
        ReferenceEquals(captured[0], captured[1])
            .ShouldBeTrue("ReadAsync must reuse the FocasAddress parsed at init, not re-parse per read");
    }

    /// <summary>
    /// Verifies that WriteAsync also uses cached FocasAddress.
    /// </summary>
    [Fact]
    public async Task WriteAsync_uses_cached_FocasAddress_too()
    {
        var captured = new List<FocasAddress>();
        var factory = new FakeFocasClientFactory
        {
            Customise = () => new CapturingFakeFocasClient(captured)
            {
                WriteStatuses = { ["R100"] = FocasStatusMapper.Good },
            },
        };
        var drv = new FocasDriver(new FocasDriverOptions
        {
            Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
            RawTags = FocasRawTags.Entries(
            [
                new FocasTagDefinition(
                    "X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte, Writable: true),
            ]),
            Probe = new FocasProbeOptions { Enabled = false },
        }, "drv-1", factory);
        await drv.InitializeAsync("{}", CancellationToken.None);

        await drv.WriteAsync(
            [new WriteRequest("X", (sbyte)1)],
            CancellationToken.None);
        await drv.WriteAsync(
            [new WriteRequest("X", (sbyte)2)],
            CancellationToken.None);

        captured.Count.ShouldBe(2);
        ReferenceEquals(captured[0], captured[1])
            .ShouldBeTrue("WriteAsync must reuse the FocasAddress parsed at init");
    }

    // ---- Driver.FOCAS-009 — Probe.Timeout applies to ProbeAsync ----

    /// <summary>
    /// Verifies that ProbeLoop cancels a slow ProbeAsync at Probe Timeout.
    /// </summary>
    [Fact]
    public async Task ProbeLoop_cancels_a_slow_ProbeAsync_at_Probe_Timeout()
    {
        // The probe loop must apply Probe.Timeout — a hung CNC socket should be cancelled at the
        // configured timeout rather than blocking until the OS TCP timeout. We prove the timeout
        // is applied by making ProbeAsync wait indefinitely and asserting it observes
        // cancellation before the normal probe Interval would tick again.
        var hangSignal = new TaskCompletionSource();
        var factory = new FakeFocasClientFactory
        {
            Customise = () => new HangingProbeFakeClient(hangSignal),
        };
        var probeTimeout = TimeSpan.FromMilliseconds(100);
        var drv = new FocasDriver(new FocasDriverOptions
        {
            Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
            Probe = new FocasProbeOptions
            {
                Enabled = true,
                Interval = TimeSpan.FromSeconds(10),
                Timeout = probeTimeout,
            },
        }, "drv-1", factory);

        await drv.InitializeAsync("{}", CancellationToken.None);

        // Wait up to a generous bound for the probe to observe cancellation. Without the
        // timeout fix, this never completes (Probe runs forever).
        var cancelled = await Task.WhenAny(
            hangSignal.Task,
            Task.Delay(TimeSpan.FromSeconds(2))) == hangSignal.Task;

        await drv.ShutdownAsync(CancellationToken.None);

        cancelled.ShouldBeTrue(
            "ProbeAsync must be cancelled at Probe.Timeout when it does not complete; otherwise a hung CNC blocks the probe loop indefinitely");
    }

    // ---- Driver.FOCAS-010 — operation-mode → text mapping is consolidated ----

    /// <summary>
    /// Verifies that OpMode ToText yields the same label in both namespaces.
    /// </summary>
    /// <param name="code">The operation mode code to test.</param>
    /// <param name="expected">The expected text representation.</param>
    [Theory]
    [InlineData(0, "MDI")]
    [InlineData(1, "AUTO")]
    [InlineData(2, "TJOG")]       // canonical FocasOpMode label, matches both surfaces post-fix
    [InlineData(3, "EDIT")]
    [InlineData(4, "HANDLE")]
    [InlineData(5, "JOG")]
    [InlineData(6, "TEACH_IN_HANDLE")]
    [InlineData(7, "REFERENCE")]
    [InlineData(8, "REMOTE")]
    [InlineData(9, "TEST")]
    public void OpMode_ToText_yields_the_same_label_in_both_namespaces(int code, string expected)
    {
        // Driver fixed-tree path (FocasOpMode.ToText) and wire layer (FocasOperationModeExtensions.ToText)
        // must yield the same canonical label so dashboard rendering doesn't vary by code path.
        FocasOpMode.ToText(code).ShouldBe(expected);
        ((FocasOperationMode)(short)code).ToText().ShouldBe(expected);
    }

    /// <summary>
    /// Verifies that OpMode ToText fallback label is consistent.
    /// </summary>
    [Fact]
    public void OpMode_ToText_fallback_label_is_consistent()
    {
        // Unknown codes must fall back to the same shape from both call sites — previously
        // FocasOpMode used "Mode{n}" while FocasOperationModeExtensions used the bare number.
        const int unknown = 99;
        FocasOpMode.ToText(unknown).ShouldBe(
            ((FocasOperationMode)(short)unknown).ToText(),
            "unknown-mode fallback must agree across both surfaces");
    }

    // ---- Driver.FOCAS-011 — FocasAlarmType constants typed as short ----

    /// <summary>
    /// Verifies that FocasAlarmType constants are typed as short.
    /// </summary>
    [Fact]
    public void FocasAlarmType_constants_are_typed_short()
    {
        // The downstream switches in FocasAlarmProjection.MapAlarmType / MapSeverity take a short.
        // Declaring the constants as int (the old shape) compiled by accident because the values
        // fit short range; making them short makes the type match the wire width.
        // We assert this at runtime via reflection so the test fails if a future contributor
        // demotes them back to int.
        var fields = typeof(FocasAlarmType).GetFields(
            System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static);
        foreach (var f in fields)
        {
            f.FieldType.ShouldBe(typeof(short),
                $"FocasAlarmType.{f.Name} must be typed `short` so it matches the wire field width " +
                "and the FocasAlarmProjection switch arm types");
        }
    }

    // ---- helpers ----

    private sealed class CapturingFakeFocasClient(List<FocasAddress> captured) : FakeFocasClient
    {
        /// <inheritdoc />
        public override Task<(object? value, uint status)> ReadAsync(
            FocasAddress address, FocasDataType type, CancellationToken ct)
        {
            captured.Add(address);
            return base.ReadAsync(address, type, ct);
        }

        /// <inheritdoc />
        public override Task<uint> WriteAsync(
            FocasAddress address, FocasDataType type, object? value, CancellationToken ct)
        {
            captured.Add(address);
            return base.WriteAsync(address, type, value, ct);
        }
    }

    private sealed class HangingProbeFakeClient(TaskCompletionSource cancelledSignal) : FakeFocasClient
    {
        /// <inheritdoc />
        public override async Task<bool> ProbeAsync(CancellationToken ct)
        {
            try
            {
                await Task.Delay(Timeout.InfiniteTimeSpan, ct).ConfigureAwait(false);
                return true;
            }
            catch (OperationCanceledException)
            {
                cancelledSignal.TrySetResult();
                throw;
            }
        }
    }
}
