using Akka.Actor;
using Akka.TestKit.Xunit2;
using Google.Protobuf.WellKnownTypes;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using ScadaLink.AuditLog.Site.Telemetry;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.Communication.Grpc;

namespace ScadaLink.AuditLog.Tests.Site.Telemetry;

/// <summary>
/// Bundle D D1 tests for <see cref="SiteAuditTelemetryActor"/>. The actor drains
/// the site SQLite queue via <see cref="ISiteAuditQueue"/>, pushes batches via
/// <see cref="ISiteStreamAuditClient"/>, and flips ack'd rows to Forwarded.
/// Both collaborators are NSubstitute mocks so the tests never touch real
/// SQLite or gRPC.
/// </summary>
public class SiteAuditTelemetryActorTests : TestKit
{
    private readonly ISiteAuditQueue _queue = Substitute.For<ISiteAuditQueue>();
    private readonly ISiteStreamAuditClient _client = Substitute.For<ISiteStreamAuditClient>();

    /// <summary>
    /// Fast options so tests don't stall waiting for the scheduler. 1s busy /
    /// 2s idle still exercises the busy-vs-idle branching, but each test
    /// completes in &lt; 5 s wall-clock.
    /// </summary>
    private static IOptions<SiteAuditTelemetryOptions> Opts(
        int batchSize = 256,
        int busySeconds = 1,
        int idleSeconds = 2) =>
        Options.Create(new SiteAuditTelemetryOptions
        {
            BatchSize = batchSize,
            BusyIntervalSeconds = busySeconds,
            IdleIntervalSeconds = idleSeconds,
        });

    private IActorRef CreateActor(IOptions<SiteAuditTelemetryOptions>? options = null) =>
        Sys.ActorOf(Props.Create(() => new SiteAuditTelemetryActor(
            _queue,
            _client,
            options ?? Opts(),
            NullLogger<SiteAuditTelemetryActor>.Instance)));

    private static AuditEvent NewEvent(Guid? id = null) => new()
    {
        EventId = id ?? Guid.NewGuid(),
        OccurredAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
        Channel = AuditChannel.ApiOutbound,
        Kind = AuditKind.ApiCall,
        Status = AuditStatus.Delivered,
        SourceSiteId = "site-1",
        ForwardState = AuditForwardState.Pending,
    };

    private static IngestAck AckAll(IReadOnlyList<AuditEvent> events)
    {
        var ack = new IngestAck();
        foreach (var e in events)
        {
            ack.AcceptedEventIds.Add(e.EventId.ToString());
        }
        return ack;
    }

    [Fact]
    public async Task Drain_With_50PendingRows_Sends_OneBatch_Of_50_Then_FlipsToForwarded()
    {
        // Arrange — 50 pending rows on the first read, then empty on subsequent
        // reads so the actor settles after one productive drain.
        var pending = Enumerable.Range(0, 50).Select(_ => NewEvent()).ToList();
        _queue.ReadPendingAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
            .Returns(
                Task.FromResult<IReadOnlyList<AuditEvent>>(pending),
                Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));

        AuditEventBatch? capturedBatch = null;
        _client.IngestAuditEventsAsync(Arg.Any<AuditEventBatch>(), Arg.Any<CancellationToken>())
            .Returns(call =>
            {
                capturedBatch = call.Arg<AuditEventBatch>();
                return Task.FromResult(AckAll(pending));
            });

        // Act
        CreateActor();

        // Assert — give the scheduler time to fire the initial Drain tick.
        await AwaitAssertAsync(async () =>
        {
            await _client.Received(1).IngestAuditEventsAsync(
                Arg.Any<AuditEventBatch>(), Arg.Any<CancellationToken>());
            await _queue.Received(1).MarkForwardedAsync(
                Arg.Is<IReadOnlyList<Guid>>(g => g.Count == 50), Arg.Any<CancellationToken>());
        }, TimeSpan.FromSeconds(5));

        Assert.NotNull(capturedBatch);
        Assert.Equal(50, capturedBatch!.Events.Count);

        var expected = pending.Select(e => e.EventId).ToHashSet();
        await _queue.Received(1).MarkForwardedAsync(
            Arg.Is<IReadOnlyList<Guid>>(g => g.ToHashSet().SetEquals(expected)),
            Arg.Any<CancellationToken>());
    }

    [Fact]
    public async Task Drain_GrpcThrows_RowsStayPending_NextDrainRetries()
    {
        // Arrange — first read returns 3 rows; the gRPC client throws on the
        // first push, then succeeds on the second. After the second push the
        // queue returns empty so the actor settles.
        var batch = Enumerable.Range(0, 3).Select(_ => NewEvent()).ToList();
        _queue.ReadPendingAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
            .Returns(
                Task.FromResult<IReadOnlyList<AuditEvent>>(batch),
                Task.FromResult<IReadOnlyList<AuditEvent>>(batch),
                Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));

        var calls = 0;
        _client.IngestAuditEventsAsync(Arg.Any<AuditEventBatch>(), Arg.Any<CancellationToken>())
            .Returns(_ =>
            {
                calls++;
                if (calls == 1)
                {
                    throw new InvalidOperationException("simulated gRPC failure");
                }
                return Task.FromResult(AckAll(batch));
            });

        // Act
        CreateActor();

        // Assert — eventually MarkForwardedAsync is called exactly once (after
        // the retry succeeded). The first failure must NOT have called
        // MarkForwardedAsync because the rows stay Pending.
        await AwaitAssertAsync(async () =>
        {
            await _queue.Received(1).MarkForwardedAsync(
                Arg.Any<IReadOnlyList<Guid>>(), Arg.Any<CancellationToken>());
        }, TimeSpan.FromSeconds(10));

        Assert.True(calls >= 2, $"Expected at least 2 client calls (1 failure + 1 retry); saw {calls}");
    }

    [Fact]
    public async Task Drain_ZeroPending_SchedulesAtIdleInterval_NoClientCall()
    {
        // Arrange — queue always empty.
        _queue.ReadPendingAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));

        // Idle interval = 2 s. Pause 3 s after the first tick (1 s busy on
        // PreStart) and assert the empty-queue branch did NOT push to the
        // client.
        CreateActor(Opts(busySeconds: 1, idleSeconds: 2));

        // Allow the initial tick (~1 s) + a generous window for the idle re-tick.
        await Task.Delay(TimeSpan.FromSeconds(3));

        await _client.DidNotReceiveWithAnyArgs().IngestAuditEventsAsync(default!, default);

        // ReadPendingAsync was called at least once (initial tick), and at
        // most twice within the 3 s window (initial + one idle re-tick).
        var readCalls = _queue.ReceivedCalls()
            .Count(c => c.GetMethodInfo().Name == nameof(ISiteAuditQueue.ReadPendingAsync));
        Assert.InRange(readCalls, 1, 2);
    }

    [Fact]
    public async Task Drain_NonZeroPending_SchedulesAtBusyInterval()
    {
        // Arrange — every read returns 1 row. With busy=1s the actor should
        // re-drain quickly, producing multiple client calls inside a short
        // window.
        var single = new List<AuditEvent> { NewEvent() };
        _queue.ReadPendingAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(single));

        _client.IngestAuditEventsAsync(Arg.Any<AuditEventBatch>(), Arg.Any<CancellationToken>())
            .Returns(call => Task.FromResult(AckAll(single)));

        CreateActor(Opts(busySeconds: 1, idleSeconds: 10));

        // 3-second window with busy=1s should fit at least 2 drains.
        await Task.Delay(TimeSpan.FromSeconds(3));

        var pushCalls = _client.ReceivedCalls()
            .Count(c => c.GetMethodInfo().Name == nameof(ISiteStreamAuditClient.IngestAuditEventsAsync));
        Assert.True(pushCalls >= 2,
            $"Expected ≥2 pushes within 3s when busy=1s; saw {pushCalls}");
    }

    [Fact]
    public async Task Drain_AcceptedEventIdsSubset_OnlyMarksAccepted()
    {
        // Arrange — 5 rows pushed, but the central ack only lists 3.
        var rows = Enumerable.Range(0, 5).Select(_ => NewEvent()).ToList();
        var ackedIds = rows.Take(3).Select(r => r.EventId).ToList();

        _queue.ReadPendingAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
            .Returns(
                Task.FromResult<IReadOnlyList<AuditEvent>>(rows),
                Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));

        var partialAck = new IngestAck();
        foreach (var id in ackedIds)
        {
            partialAck.AcceptedEventIds.Add(id.ToString());
        }
        _client.IngestAuditEventsAsync(Arg.Any<AuditEventBatch>(), Arg.Any<CancellationToken>())
            .Returns(Task.FromResult(partialAck));

        // Act
        CreateActor();

        await AwaitAssertAsync(async () =>
        {
            await _queue.Received(1).MarkForwardedAsync(
                Arg.Any<IReadOnlyList<Guid>>(), Arg.Any<CancellationToken>());
        }, TimeSpan.FromSeconds(5));

        // Assert — exactly the 3 ack'd ids made it to MarkForwardedAsync, not
        // the other 2.
        var ackedSet = ackedIds.ToHashSet();
        await _queue.Received(1).MarkForwardedAsync(
            Arg.Is<IReadOnlyList<Guid>>(g => g.Count == 3 && g.ToHashSet().SetEquals(ackedSet)),
            Arg.Any<CancellationToken>());
    }
}
