using Akka.TestKit.Xunit2;
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;

namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;

/// <summary>
/// Tests for <see cref="SiteStreamGrpcServer.PullSiteCalls"/> (Site Call Audit
/// #22 reconciliation handler). Verifies the request →
/// <see cref="IOperationTrackingStore.ReadChangedSinceAsync"/> → response
/// round-trip through the gRPC handler. The store is an NSubstitute stub so the
/// tests never touch SQLite. Mirrors <see cref="SiteStreamPullAuditEventsTests"/>
/// — but there is no MarkReconciled step (the tracking store is the operational
/// source of truth; the central SiteCalls mirror is upsert-on-newer).
/// </summary>
public class SiteStreamPullSiteCallsTests : TestKit
{
    private readonly ISiteStreamSubscriber _subscriber = Substitute.For<ISiteStreamSubscriber>();

    private SiteStreamGrpcServer CreateServer() =>
        new(_subscriber, NullLogger<SiteStreamGrpcServer>.Instance);

    private static ServerCallContext NewContext(CancellationToken ct = default)
    {
        var context = Substitute.For<ServerCallContext>();
        context.CancellationToken.Returns(ct);
        return context;
    }

    private static SiteCallOperational NewOperational() =>
        new(
            TrackedOperationId: TrackedOperationId.New(),
            Channel: "ApiOutbound",
            Target: "ERP.GetOrder",
            SourceSite: string.Empty,
            SourceNode: "node-a",
            Status: "Attempted",
            RetryCount: 1,
            LastError: null,
            HttpStatus: 503,
            CreatedAtUtc: DateTime.SpecifyKind(new DateTime(2026, 5, 20, 10, 0, 0), DateTimeKind.Utc),
            UpdatedAtUtc: DateTime.SpecifyKind(new DateTime(2026, 5, 20, 10, 1, 0), DateTimeKind.Utc),
            TerminalAtUtc: null);

    [Fact]
    public async Task PullSiteCalls_NoStoreWired_ReturnsEmptyResponse()
    {
        var server = CreateServer();
        // Intentionally do NOT call SetOperationTrackingStore — simulates a
        // central-only host or a wiring-incomplete startup window.

        var request = new PullSiteCallsRequest
        {
            SinceUtc = Timestamp.FromDateTime(DateTime.UtcNow.AddMinutes(-5)),
            BatchSize = 100,
        };

        var response = await server.PullSiteCalls(request, NewContext());

        Assert.Empty(response.Operationals);
        Assert.False(response.MoreAvailable);
    }

    [Fact]
    public async Task PullSiteCalls_With5Rows_ReturnsAllFiveDtos()
    {
        var store = Substitute.For<IOperationTrackingStore>();
        var rows = Enumerable.Range(0, 5).Select(_ => NewOperational()).ToList();
        store.ReadChangedSinceAsync(Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
            .Returns((IReadOnlyList<SiteCallOperational>)rows);

        var server = CreateServer();
        server.SetOperationTrackingStore(store);

        var request = new PullSiteCallsRequest
        {
            SinceUtc = Timestamp.FromDateTime(DateTime.UtcNow.AddHours(-1)),
            BatchSize = 100, // larger than returned count so MoreAvailable should be false
        };

        var response = await server.PullSiteCalls(request, NewContext());

        Assert.Equal(5, response.Operationals.Count);
        Assert.False(response.MoreAvailable); // 5 < 100
        var expectedIds = rows.Select(r => r.TrackedOperationId.ToString()).ToHashSet();
        Assert.True(expectedIds.SetEquals(response.Operationals.Select(d => d.TrackedOperationId).ToHashSet()));
    }

    [Fact]
    public async Task PullSiteCalls_PassesSinceUtcThroughVerbatim()
    {
        var store = Substitute.For<IOperationTrackingStore>();
        var capturedSince = DateTime.MinValue;
        store.ReadChangedSinceAsync(Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
            .Returns(call =>
            {
                capturedSince = call.ArgAt<DateTime>(0);
                return (IReadOnlyList<SiteCallOperational>)Array.Empty<SiteCallOperational>();
            });

        var server = CreateServer();
        server.SetOperationTrackingStore(store);

        var since = DateTime.SpecifyKind(new DateTime(2026, 5, 20, 9, 30, 0), DateTimeKind.Utc);
        var request = new PullSiteCallsRequest
        {
            SinceUtc = Timestamp.FromDateTime(since),
            BatchSize = 50,
        };

        var response = await server.PullSiteCalls(request, NewContext());

        Assert.Empty(response.Operationals);
        Assert.False(response.MoreAvailable);
        Assert.Equal(since, capturedSince);
    }

    [Fact]
    public async Task PullSiteCalls_AfterIdSet_PassesCursorThroughToStore()
    {
        // Task 15: a non-empty after_id is forwarded verbatim to the store as the
        // composite-keyset cursor.
        var store = Substitute.For<IOperationTrackingStore>();
        string? capturedAfterId = "SENTINEL";
        store.ReadChangedSinceAsync(Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
            .Returns(call =>
            {
                capturedAfterId = call.ArgAt<string?>(2);
                return (IReadOnlyList<SiteCallOperational>)Array.Empty<SiteCallOperational>();
            });

        var server = CreateServer();
        server.SetOperationTrackingStore(store);

        var cursor = Guid.NewGuid().ToString("D");
        var request = new PullSiteCallsRequest
        {
            SinceUtc = Timestamp.FromDateTime(DateTime.UtcNow.AddHours(-1)),
            BatchSize = 50,
            AfterId = cursor,
        };

        await server.PullSiteCalls(request, NewContext());

        Assert.Equal(cursor, capturedAfterId);
    }

    [Fact]
    public async Task PullSiteCalls_AfterIdUnset_PassesNullToStore()
    {
        // proto3 defaults an unset string to ""; the handler must normalise that to
        // null so the store keeps its legacy inclusive >= behaviour (older central).
        var store = Substitute.For<IOperationTrackingStore>();
        string? capturedAfterId = "SENTINEL";
        store.ReadChangedSinceAsync(Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
            .Returns(call =>
            {
                capturedAfterId = call.ArgAt<string?>(2);
                return (IReadOnlyList<SiteCallOperational>)Array.Empty<SiteCallOperational>();
            });

        var server = CreateServer();
        server.SetOperationTrackingStore(store);

        var request = new PullSiteCallsRequest
        {
            SinceUtc = Timestamp.FromDateTime(DateTime.UtcNow.AddHours(-1)),
            BatchSize = 50,
            // AfterId left unset → "" on the wire.
        };

        await server.PullSiteCalls(request, NewContext());

        Assert.Null(capturedAfterId);
    }

    [Fact]
    public async Task PullSiteCalls_SinceUtcUnset_PassesDateTimeMinValue()
    {
        // First reconciliation cycle: central has no cursor yet, so the request's
        // SinceUtc wrapper is absent (null). The handler must default to
        // DateTime.MinValue ("pull from the beginning of recorded history")
        // without a null-deref — this proves the very first cycle doesn't crash.
        var store = Substitute.For<IOperationTrackingStore>();
        var captured = new DateTime(2099, 1, 1, 0, 0, 0, DateTimeKind.Utc); // sentinel
        store.ReadChangedSinceAsync(Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
            .Returns(call =>
            {
                captured = call.ArgAt<DateTime>(0);
                return (IReadOnlyList<SiteCallOperational>)Array.Empty<SiteCallOperational>();
            });

        var server = CreateServer();
        server.SetOperationTrackingStore(store);

        // SinceUtc intentionally left unset (null) — the proto wrapper is absent.
        var request = new PullSiteCallsRequest
        {
            BatchSize = 100,
        };

        var response = await server.PullSiteCalls(request, NewContext());

        Assert.Empty(response.Operationals);
        Assert.False(response.MoreAvailable);
        Assert.Equal(DateTime.MinValue, captured);
    }

    [Fact]
    public async Task PullSiteCalls_BatchSize3_Returns3Rows_MoreAvailableTrue()
    {
        var store = Substitute.For<IOperationTrackingStore>();
        var rows = Enumerable.Range(0, 3).Select(_ => NewOperational()).ToList();
        store.ReadChangedSinceAsync(Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
            .Returns((IReadOnlyList<SiteCallOperational>)rows);

        var server = CreateServer();
        server.SetOperationTrackingStore(store);

        var request = new PullSiteCallsRequest
        {
            SinceUtc = Timestamp.FromDateTime(DateTime.UtcNow.AddHours(-1)),
            BatchSize = 3,
        };

        var response = await server.PullSiteCalls(request, NewContext());

        Assert.Equal(3, response.Operationals.Count);
        // saturated batch → central needs to know to issue a follow-up pull
        Assert.True(response.MoreAvailable);
    }

    [Fact]
    public async Task PullSiteCalls_NonPositiveBatchSize_ThrowsInvalidArgument()
    {
        var store = Substitute.For<IOperationTrackingStore>();
        var server = CreateServer();
        server.SetOperationTrackingStore(store);

        var request = new PullSiteCallsRequest
        {
            SinceUtc = Timestamp.FromDateTime(DateTime.UtcNow.AddHours(-1)),
            BatchSize = 0,
        };

        var ex = await Assert.ThrowsAsync<RpcException>(
            () => server.PullSiteCalls(request, NewContext()));
        Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode);
    }

    [Fact]
    public async Task PullSiteCalls_ReadThrows_ReturnsEmptyResponse()
    {
        // Best-effort: a read fault must never abort the reconciliation tick.
        var store = Substitute.For<IOperationTrackingStore>();
        store.ReadChangedSinceAsync(Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
            .ThrowsAsync(new InvalidOperationException("SQLite disposed mid-call"));

        var server = CreateServer();
        server.SetOperationTrackingStore(store);

        var request = new PullSiteCallsRequest
        {
            SinceUtc = Timestamp.FromDateTime(DateTime.UtcNow.AddHours(-1)),
            BatchSize = 100,
        };

        // Must NOT throw — the handler swallows the fault to an empty response.
        var response = await server.PullSiteCalls(request, NewContext());

        Assert.Empty(response.Operationals);
        Assert.False(response.MoreAvailable);
    }
}
