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

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

/// <summary>
///     Task 10 — <see cref="MTConnectDriver"/>'s <see cref="IReadable"/> half: one <c>/current</c>
///     per batch, one snapshot per requested reference in request order, and a Bad code for every
///     shape of failure. Every test runs against <see cref="CannedAgentClient"/>; no socket is
///     opened anywhere in this file.
/// </summary>
/// <remarks>
///     <para>
///         <b>The load-bearing assertions are about arity, order, and not-throwing.</b> The caller
///         (the runtime's dispatch layer) indexes the returned list <i>positionally</i> against the
///         references it asked for, so a length mismatch or a reorder is silent data corruption —
///         every value lands on the wrong tag under Good quality. And every failure this driver can
///         meet at read time (agent down, deadline blown, unparseable answer, unknown id, driver not
///         initialized) must arrive as a Bad-coded snapshot: an exception out of
///         <see cref="IReadable.ReadAsync"/> fails the whole batch including the references that
///         were perfectly readable.
///     </para>
///     <para>
///         <b>The one exception that may propagate is genuine caller cancellation</b>, and it is
///         pinned here alongside its opposite (an agent failure under an uncancelled token) — the
///         two are one <c>catch</c> filter apart in the implementation, and getting the filter
///         backwards turns every dead agent into a thrown batch.
///     </para>
/// </remarks>
public sealed class MTConnectReadTests
{
    private const string AgentUri = "http://fixture-agent:5000";

    // Canonical Opc.Ua.StatusCodes numerics, restated here so the test asserts the wire value an
    // OPC UA client actually sees rather than a driver-private constant it could drift with.
    private const uint Good = 0x00000000u;
    private const uint BadMask = 0x80000000u;
    private const uint BadCommunicationError = 0x80050000u;
    private const uint BadNoCommunication = 0x80310000u;
    private const uint BadWaitingForInitialData = 0x80320000u;
    private const uint BadNodeIdUnknown = 0x80340000u;
    private const uint BadNotSupported = 0x803D0000u;
    private const uint BadNotConnected = 0x808A0000u;

    /// <summary>
    ///     Authored tags spanning every read outcome the fixtures can produce: a coercible Good
    ///     value, an <c>UNAVAILABLE</c> one, a TIME_SERIES vector, and one authored id no fixture
    ///     ever reports.
    /// </summary>
    private static MTConnectDriverOptions Opts(int requestTimeoutMs = 5000) =>
        new()
        {
            AgentUri = AgentUri,
            RequestTimeoutMs = requestTimeoutMs,
            Tags =
            [
                new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64),
                new MTConnectTagDefinition("dev1_execution", DriverDataType.String),
                new MTConnectTagDefinition("dev1_partcount", DriverDataType.Int32),
                new MTConnectTagDefinition("dev1_vibration_ts", DriverDataType.Float64, IsArray: true, ArrayDim: 10),
                new MTConnectTagDefinition("dev1_never_reported", DriverDataType.Int32),
            ],
        };

    private static (MTConnectDriver Driver, CannedAgentClient Client) NewDriver(
        MTConnectDriverOptions? options = null)
    {
        var client = CannedAgentClient.FromFixtures();

        return (new MTConnectDriver(options ?? Opts(), "mt1", _ => client), client);
    }

    private static async Task<(MTConnectDriver Driver, CannedAgentClient Client)> InitializedDriverAsync(
        MTConnectDriverOptions? options = null)
    {
        var (driver, client) = NewDriver(options);
        await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);

        return (driver, client);
    }

    // ---- arity + order: the contract the caller indexes positionally ----

    /// <summary>The plan's TDD case: one snapshot per ref, in order, absent ⇒ Bad rather than a throw.</summary>
    [Fact]
    public async Task Read_returns_one_snapshot_per_ref_in_order_absent_ref_is_bad()
    {
        var (driver, _) = await InitializedDriverAsync();

        var res = await driver.ReadAsync(["dev1_pos", "does-not-exist"], TestContext.Current.CancellationToken);

        res.Count.ShouldBe(2);
        res[0].StatusCode.ShouldBe(Good);
        (res[1].StatusCode & BadMask).ShouldBe(BadMask);
    }

    /// <summary>
    ///     A mixed batch requested in an order that matches neither the document order nor any
    ///     dictionary enumeration order. This is the assertion that catches a driver that answers
    ///     with the index's own key order instead of the request's.
    /// </summary>
    [Fact]
    public async Task Read_answers_in_request_order_not_document_order()
    {
        var (driver, _) = await InitializedDriverAsync();

        var res = await driver.ReadAsync(
            ["dev1_program", "dev1_pos", "does-not-exist", "dev1_partcount", "dev1_execution"],
            TestContext.Current.CancellationToken);

        res.Count.ShouldBe(5);

        // dev1_program is observed but NOT authored — String is the only coercion that cannot fail.
        res[0].StatusCode.ShouldBe(Good);
        res[0].Value.ShouldBe("O1234");

        res[1].StatusCode.ShouldBe(Good);
        res[1].Value.ShouldBe(123.4567d);

        res[2].StatusCode.ShouldBe(BadNodeIdUnknown);

        res[3].StatusCode.ShouldBe(BadNoCommunication);

        res[4].StatusCode.ShouldBe(Good);
        res[4].Value.ShouldBe("ACTIVE");
    }

    /// <summary>
    ///     A reference repeated in one request gets a snapshot per occurrence. De-duplicating would
    ///     shorten the list and shift every later reference onto the wrong tag.
    /// </summary>
    [Fact]
    public async Task Read_returns_a_snapshot_per_duplicate_occurrence()
    {
        var (driver, _) = await InitializedDriverAsync();

        var res = await driver.ReadAsync(
            ["dev1_pos", "dev1_execution", "dev1_pos"], TestContext.Current.CancellationToken);

        res.Count.ShouldBe(3);
        res[0].StatusCode.ShouldBe(Good);
        res[0].Value.ShouldBe(123.4567d);
        res[1].Value.ShouldBe("ACTIVE");
        res[2].StatusCode.ShouldBe(Good);
        res[2].Value.ShouldBe(123.4567d);
    }

    /// <summary>An empty batch is empty — and, being empty, costs the Agent nothing.</summary>
    [Fact]
    public async Task Read_of_an_empty_batch_is_empty_and_costs_no_round_trip()
    {
        var (driver, client) = await InitializedDriverAsync();
        var before = client.CurrentCallCount;

        var res = await driver.ReadAsync([], TestContext.Current.CancellationToken);

        res.Count.ShouldBe(0);
        client.CurrentCallCount.ShouldBe(before);
    }

    /// <summary>
    ///     A batch of N references costs exactly ONE <c>/current</c>. The whole point of reading the
    ///     Agent's whole-device snapshot is that per-tag round-trips never happen.
    /// </summary>
    [Fact]
    public async Task Read_of_many_refs_costs_exactly_one_current_call()
    {
        var (driver, client) = await InitializedDriverAsync();
        var before = client.CurrentCallCount;

        var res = await driver.ReadAsync(
            ["dev1_pos", "dev1_execution", "dev1_partcount", "dev1_program", "does-not-exist", "dev1_pos"],
            TestContext.Current.CancellationToken);

        res.Count.ShouldBe(6);
        client.CurrentCallCount.ShouldBe(before + 1);
    }

    // ---- status-code fidelity: the index's distinctions must survive the trip ----

    /// <summary>A genuinely unknown id — neither authored nor ever observed.</summary>
    [Fact]
    public async Task Read_of_an_unknown_ref_reports_BadNodeIdUnknown()
    {
        var (driver, _) = await InitializedDriverAsync();

        var res = await driver.ReadAsync(["nope"], TestContext.Current.CancellationToken);

        res[0].StatusCode.ShouldBe(BadNodeIdUnknown);
        res[0].Value.ShouldBeNull();
    }

    /// <summary>
    ///     An authored id the Agent has never reported is <c>BadWaitingForInitialData</c>, NOT
    ///     <c>BadNodeIdUnknown</c> — collapsing the two would tell an operator their correctly
    ///     authored tag does not exist.
    /// </summary>
    [Fact]
    public async Task Read_of_an_authored_but_unreported_ref_reports_BadWaitingForInitialData()
    {
        var (driver, _) = await InitializedDriverAsync();

        var res = await driver.ReadAsync(["dev1_never_reported"], TestContext.Current.CancellationToken);

        res[0].StatusCode.ShouldBe(BadWaitingForInitialData);
    }

    /// <summary>The Agent said <c>UNAVAILABLE</c>: reachable Agent, absent machine value.</summary>
    [Fact]
    public async Task Read_of_an_unavailable_ref_reports_BadNoCommunication()
    {
        var (driver, _) = await InitializedDriverAsync();

        var res = await driver.ReadAsync(["dev1_partcount"], TestContext.Current.CancellationToken);

        res[0].StatusCode.ShouldBe(BadNoCommunication);
        res[0].Value.ShouldBeNull();
        res[0].SourceTimestampUtc.ShouldNotBeNull();
    }

    /// <summary>A TIME_SERIES vector is real data this build cannot represent (P1.5 deferral).</summary>
    [Fact]
    public async Task Read_of_a_time_series_ref_reports_BadNotSupported()
    {
        var (driver, _) = await InitializedDriverAsync();

        var res = await driver.ReadAsync(["dev1_vibration_ts"], TestContext.Current.CancellationToken);

        res[0].StatusCode.ShouldBe(BadNotSupported);
    }

    /// <summary>A blank reference is data, not a programming error — it codes Bad, it does not throw.</summary>
    [Fact]
    public async Task Read_of_a_blank_ref_is_bad_not_a_throw()
    {
        var (driver, _) = await InitializedDriverAsync();

        var res = await driver.ReadAsync(["", "   ", "dev1_pos"], TestContext.Current.CancellationToken);

        res.Count.ShouldBe(3);
        res[0].StatusCode.ShouldBe(BadNodeIdUnknown);
        res[1].StatusCode.ShouldBe(BadNodeIdUnknown);
        res[2].StatusCode.ShouldBe(Good);
    }

    /// <summary>The authored type is honoured: the Agent's text becomes a CLR value of that type.</summary>
    [Fact]
    public async Task Read_coerces_the_agent_text_to_the_authored_type()
    {
        var (driver, _) = await InitializedDriverAsync();

        var res = await driver.ReadAsync(["dev1_pos"], TestContext.Current.CancellationToken);

        res[0].StatusCode.ShouldBe(Good);
        res[0].Value.ShouldBeOfType<double>().ShouldBe(123.4567d);
        res[0].SourceTimestampUtc.ShouldBe(new DateTime(2026, 7, 24, 12, 0, 0, 200, DateTimeKind.Utc));
    }

    // ---- freshness: a read is a read, not a replay of the initialize baseline ----

    /// <summary>
    ///     The whole justification for spending a round-trip: an OPC UA Read must reflect the
    ///     device's <i>current</i> state, not whatever <see cref="MTConnectDriver.InitializeAsync"/>
    ///     happened to prime. A driver that served the primed index would pass every other test in
    ///     this file.
    /// </summary>
    [Fact]
    public async Task Read_reflects_the_agent_state_at_read_time_not_the_initialize_baseline()
    {
        var (driver, client) = await InitializedDriverAsync();

        // The Agent has moved on since the priming /current (sample.xml reports 124.0100).
        client.Current = CannedAgentClient.Chunk("Fixtures/sample.xml");

        var res = await driver.ReadAsync(["dev1_pos"], TestContext.Current.CancellationToken);

        res[0].StatusCode.ShouldBe(Good);
        res[0].Value.ShouldBe(124.01d);
    }

    /// <summary>
    ///     <b>The anti-clobber pin.</b> <c>ReadAsync</c> indexes its <c>/current</c> into a
    ///     per-call snapshot and never writes the driver's shared observation index — that index has
    ///     exactly one writer, the Task 11 <c>/sample</c> pump. If a read wrote it, a <c>/current</c>
    ///     document older than the pump's newest delta would silently roll a subscribed value
    ///     backwards and leave it there until the next change.
    /// </summary>
    [Fact]
    public async Task Read_does_not_write_the_shared_observation_index()
    {
        var (driver, client) = await InitializedDriverAsync();
        var primed = driver.ObservationIndex.Get("dev1_pos");

        client.Current = CannedAgentClient.Chunk("Fixtures/sample.xml");
        var res = await driver.ReadAsync(["dev1_pos"], TestContext.Current.CancellationToken);

        // The read saw the new value...
        res[0].Value.ShouldBe(124.01d);

        // ...and the shared index still holds exactly what the pump/priming left there.
        var after = driver.ObservationIndex.Get("dev1_pos");
        after.Value.ShouldBe(primed.Value);
        after.Value.ShouldBe(123.4567d);
    }

    // ---- failure posture: Bad snapshots, never exceptions ----

    /// <summary>
    ///     An unreachable Agent fails the batch's values, not the batch. Every reference codes
    ///     <c>BadCommunicationError</c> and the call returns normally.
    /// </summary>
    [Fact]
    public async Task Read_codes_every_ref_bad_when_the_agent_call_fails()
    {
        var (driver, client) = await InitializedDriverAsync();
        client.CurrentFailure = new HttpRequestException("connection refused");

        var res = await driver.ReadAsync(
            ["dev1_pos", "dev1_execution", "does-not-exist"], TestContext.Current.CancellationToken);

        res.Count.ShouldBe(3);
        res.ShouldAllBe(r => r.StatusCode == BadCommunicationError);
        res.ShouldAllBe(r => r.Value == null);
    }

    /// <summary>An answer the driver cannot make sense of is a Bad batch, not a thrown one.</summary>
    [Fact]
    public async Task Read_codes_every_ref_bad_when_the_agent_answer_is_unusable()
    {
        var (driver, client) = await InitializedDriverAsync();
        client.CurrentFailure = new System.Xml.XmlException("unexpected end of document");

        var res = await driver.ReadAsync(["dev1_pos"], TestContext.Current.CancellationToken);

        res[0].StatusCode.ShouldBe(BadCommunicationError);
    }

    /// <summary>
    ///     A failed read degrades the driver rather than leaving it advertising Healthy, and a later
    ///     successful read restores it. <c>LastSuccessfulRead</c> survives the degradation — it is
    ///     the "when did this driver last hear from the Agent" diagnostic.
    /// </summary>
    [Fact]
    public async Task Read_failure_degrades_the_driver_and_a_later_success_restores_it()
    {
        var (driver, client) = await InitializedDriverAsync();
        client.CurrentFailure = new HttpRequestException("connection refused");

        await driver.ReadAsync(["dev1_pos"], TestContext.Current.CancellationToken);

        var degraded = driver.GetHealth();
        degraded.State.ShouldBe(DriverState.Degraded);
        degraded.LastSuccessfulRead.ShouldNotBeNull();
        degraded.LastError.ShouldNotBeNullOrWhiteSpace();

        client.CurrentFailure = null;
        await driver.ReadAsync(["dev1_pos"], TestContext.Current.CancellationToken);

        driver.GetHealth().State.ShouldBe(DriverState.Healthy);
    }

    /// <summary>
    ///     R2-01 — the <c>/current</c> runs under a bounded deadline. An Agent that accepts the
    ///     request and never answers surfaces as a Bad batch on the driver's own clock rather than
    ///     wedging the caller forever.
    /// </summary>
    [Fact]
    public async Task Read_is_bounded_by_the_per_call_deadline()
    {
        var (driver, client) = await InitializedDriverAsync(Opts(requestTimeoutMs: 50));
        var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        client.CurrentGate = gate;

        try
        {
            var res = await driver.ReadAsync(["dev1_pos"], TestContext.Current.CancellationToken);

            res.Count.ShouldBe(1);
            res[0].StatusCode.ShouldBe(BadCommunicationError);
        }
        finally
        {
            gate.TrySetResult();
        }
    }

    /// <summary>
    ///     The one exception that may cross the capability boundary. Note the contrast with
    ///     <see cref="Read_codes_every_ref_bad_when_the_agent_call_fails"/>: same code path, one
    ///     <c>catch</c> filter apart.
    /// </summary>
    [Fact]
    public async Task Read_propagates_genuine_caller_cancellation()
    {
        var (driver, _) = await InitializedDriverAsync();
        using var cts = new CancellationTokenSource();
        await cts.CancelAsync();

        await Should.ThrowAsync<OperationCanceledException>(
            () => driver.ReadAsync(["dev1_pos"], cts.Token));
    }

    // ---- reads outside the initialized window ----

    /// <summary>
    ///     A read before <c>InitializeAsync</c> has no Agent to ask. It reports
    ///     <c>BadNotConnected</c> for every reference — it does not throw, it does not
    ///     <c>NullReferenceException</c>, and it does not invent a value.
    /// </summary>
    [Fact]
    public async Task Read_before_initialize_is_bad_not_connected()
    {
        var (driver, client) = NewDriver();

        var res = await driver.ReadAsync(["dev1_pos", "nope"], TestContext.Current.CancellationToken);

        res.Count.ShouldBe(2);
        res.ShouldAllBe(r => r.StatusCode == BadNotConnected);
        client.CurrentCallCount.ShouldBe(0);

        // An un-started driver is Unknown, not Degraded: nothing has failed yet.
        driver.GetHealth().State.ShouldBe(DriverState.Unknown);
    }

    /// <summary>
    ///     Same posture after <c>ShutdownAsync</c>, and shutting down must stay reported as
    ///     <see cref="DriverState.Unknown"/> — a read attempted against a deliberately stopped
    ///     driver is not a degradation.
    /// </summary>
    [Fact]
    public async Task Read_after_shutdown_is_bad_not_connected_and_leaves_health_alone()
    {
        var (driver, _) = await InitializedDriverAsync();
        await driver.ShutdownAsync(TestContext.Current.CancellationToken);

        var res = await driver.ReadAsync(["dev1_pos"], TestContext.Current.CancellationToken);

        res[0].StatusCode.ShouldBe(BadNotConnected);
        driver.GetHealth().State.ShouldBe(DriverState.Unknown);
    }

    /// <summary>
    ///     A <c>null</c> reference list is a caller bug, not device data — the one place this method
    ///     is allowed to be loud, matching <see cref="MTConnectObservationIndex"/>'s documented
    ///     posture ("the only exceptions raised are <c>ArgumentNullException</c> for a null argument").
    /// </summary>
    [Fact]
    public async Task Read_of_a_null_ref_list_throws_ArgumentNullException()
    {
        var (driver, _) = await InitializedDriverAsync();

        await Should.ThrowAsync<ArgumentNullException>(
            () => driver.ReadAsync(null!, TestContext.Current.CancellationToken));
    }
}
