// Go: consumer.go:500-600 — Priority group tests for sticky consumer assignment.
// Validates that the lowest-priority-numbered consumer is "active" and that
// failover occurs correctly when consumers register/unregister.
using System.Collections.Concurrent;
using System.Text;
using NATS.Server.JetStream;
using NATS.Server.JetStream.Consumers;
using NATS.Server.JetStream.Models;
using NATS.Server.JetStream.Storage;

namespace NATS.Server.JetStream.Tests.JetStream.Consumers;

public class PriorityGroupTests
{
    // -------------------------------------------------------------------------
    // Test 1 — Single consumer registered is active
    //
    // Go reference: consumer.go:500 — when only one consumer is in a priority
    // group, it is unconditionally the active consumer.
    // -------------------------------------------------------------------------
    [Fact]
    public void Register_SingleConsumer_IsActive()
    {
        var mgr = new PriorityGroupManager();
        mgr.Register("group1", "consumer-a", priority: 1);

        mgr.IsActive("group1", "consumer-a").ShouldBeTrue();
        mgr.GetActiveConsumer("group1").ShouldBe("consumer-a");
    }

    // -------------------------------------------------------------------------
    // Test 2 — Multiple consumers: lowest priority number wins
    //
    // Go reference: consumer.go:510 — the consumer with the lowest priority
    // number is the active consumer. Priority 1 < Priority 5, so 1 wins.
    // -------------------------------------------------------------------------
    [Fact]
    public void Register_MultipleConsumers_LowestPriorityIsActive()
    {
        var mgr = new PriorityGroupManager();
        mgr.Register("group1", "consumer-high", priority: 5);
        mgr.Register("group1", "consumer-low", priority: 1);
        mgr.Register("group1", "consumer-mid", priority: 3);

        mgr.GetActiveConsumer("group1").ShouldBe("consumer-low");
        mgr.IsActive("group1", "consumer-low").ShouldBeTrue();
        mgr.IsActive("group1", "consumer-high").ShouldBeFalse();
        mgr.IsActive("group1", "consumer-mid").ShouldBeFalse();
    }

    // -------------------------------------------------------------------------
    // Test 3 — Unregister active consumer: next takes over
    //
    // Go reference: consumer.go:530 — when the active consumer disconnects,
    // the next-lowest-priority consumer becomes active (failover).
    // -------------------------------------------------------------------------
    [Fact]
    public void Unregister_ActiveConsumer_NextTakesOver()
    {
        var mgr = new PriorityGroupManager();
        mgr.Register("group1", "consumer-a", priority: 1);
        mgr.Register("group1", "consumer-b", priority: 2);
        mgr.Register("group1", "consumer-c", priority: 3);

        mgr.GetActiveConsumer("group1").ShouldBe("consumer-a");

        mgr.Unregister("group1", "consumer-a");

        mgr.GetActiveConsumer("group1").ShouldBe("consumer-b");
        mgr.IsActive("group1", "consumer-b").ShouldBeTrue();
        mgr.IsActive("group1", "consumer-a").ShouldBeFalse();
    }

    // -------------------------------------------------------------------------
    // Test 4 — Unregister non-active consumer: active unchanged
    //
    // Go reference: consumer.go:540 — removing a non-active consumer does not
    // change the active assignment.
    // -------------------------------------------------------------------------
    [Fact]
    public void Unregister_NonActiveConsumer_ActiveUnchanged()
    {
        var mgr = new PriorityGroupManager();
        mgr.Register("group1", "consumer-a", priority: 1);
        mgr.Register("group1", "consumer-b", priority: 2);

        mgr.GetActiveConsumer("group1").ShouldBe("consumer-a");

        mgr.Unregister("group1", "consumer-b");

        mgr.GetActiveConsumer("group1").ShouldBe("consumer-a");
        mgr.IsActive("group1", "consumer-a").ShouldBeTrue();
    }

    // -------------------------------------------------------------------------
    // Test 5 — Same priority: first registered wins
    //
    // Go reference: consumer.go:520 — when two consumers share the same
    // priority, the first to register is treated as the active consumer.
    // -------------------------------------------------------------------------
    [Fact]
    public void Register_SamePriority_FirstRegisteredWins()
    {
        var mgr = new PriorityGroupManager();
        mgr.Register("group1", "consumer-first", priority: 1);
        mgr.Register("group1", "consumer-second", priority: 1);

        mgr.GetActiveConsumer("group1").ShouldBe("consumer-first");
        mgr.IsActive("group1", "consumer-first").ShouldBeTrue();
        mgr.IsActive("group1", "consumer-second").ShouldBeFalse();
    }

    // -------------------------------------------------------------------------
    // Test 6 — Empty group returns null
    //
    // Go reference: consumer.go:550 — calling GetActiveConsumer on an empty
    // or nonexistent group returns nil (null).
    // -------------------------------------------------------------------------
    [Fact]
    public void GetActiveConsumer_EmptyGroup_ReturnsNull()
    {
        var mgr = new PriorityGroupManager();

        mgr.GetActiveConsumer("nonexistent").ShouldBeNull();
        mgr.IsActive("nonexistent", "any-consumer").ShouldBeFalse();
    }

    // -------------------------------------------------------------------------
    // Test 7 — Idle heartbeat sent after timeout
    //
    // Go reference: consumer.go:5222 — sendIdleHeartbeat is invoked by a
    // background timer when no data frames are delivered within HeartbeatMs.
    // -------------------------------------------------------------------------
    [Fact]
    public async Task IdleHeartbeat_SentAfterTimeout()
    {
        var engine = new PushConsumerEngine();
        var consumer = new ConsumerHandle("TEST-STREAM", new ConsumerConfig
        {
            DurableName = "HB-CONSUMER",
            Push = true,
            DeliverSubject = "deliver.hb",
            HeartbeatMs = 50, // 50ms heartbeat interval
        });

        var sent = new ConcurrentBag<(string Subject, string ReplyTo, byte[] Headers, byte[] Payload)>();

        ValueTask SendCapture(string subject, string replyTo, ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload, CancellationToken ct)
        {
            sent.Add((subject, replyTo, headers.ToArray(), payload.ToArray()));
            return ValueTask.CompletedTask;
        }

        using var cts = new CancellationTokenSource();

        engine.StartDeliveryLoop(consumer, SendCapture, cts.Token);

        // Wait long enough for at least one idle heartbeat to fire
        await Task.Delay(200);

        engine.StopDeliveryLoop();

        engine.IdleHeartbeatsSent.ShouldBeGreaterThan(0);

        // Verify the heartbeat messages were sent to the deliver subject
        var hbMessages = sent.Where(s =>
            Encoding.ASCII.GetString(s.Headers).Contains("Idle Heartbeat")).ToList();
        hbMessages.Count.ShouldBeGreaterThan(0);
        hbMessages.ShouldAllBe(m => m.Subject == "deliver.hb");
    }

    // -------------------------------------------------------------------------
    // Test 8 — Idle heartbeat resets on data delivery
    //
    // Go reference: consumer.go:5222 — the idle heartbeat timer is reset
    // whenever a data frame is delivered, so heartbeats only fire during
    // periods of inactivity.
    // -------------------------------------------------------------------------
    [Fact]
    public async Task IdleHeartbeat_ResetOnDataDelivery()
    {
        var engine = new PushConsumerEngine();
        var consumer = new ConsumerHandle("TEST-STREAM", new ConsumerConfig
        {
            DurableName = "HB-RESET",
            Push = true,
            DeliverSubject = "deliver.hbreset",
            HeartbeatMs = 100, // 100ms heartbeat interval
        });

        var dataFramesSent = new ConcurrentBag<string>();
        var heartbeatsSent = new ConcurrentBag<string>();

        ValueTask SendCapture(string subject, string replyTo, ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload, CancellationToken ct)
        {
            var headerStr = Encoding.ASCII.GetString(headers.Span);
            if (headerStr.Contains("Idle Heartbeat"))
                heartbeatsSent.Add(subject);
            else
                dataFramesSent.Add(subject);
            return ValueTask.CompletedTask;
        }

        using var cts = new CancellationTokenSource();

        engine.StartDeliveryLoop(consumer, SendCapture, cts.Token);

        // Continuously enqueue data messages faster than the heartbeat interval
        // to keep the timer resetting. Each data delivery resets the idle heartbeat.
        for (var i = 0; i < 5; i++)
        {
            engine.Enqueue(consumer, new StoredMessage
            {
                Sequence = (ulong)(i + 1),
                Subject = "test.data",
                Payload = Encoding.UTF8.GetBytes($"msg-{i}"),
                TimestampUtc = DateTime.UtcNow,
            });
            await Task.Delay(30); // 30ms between messages — well within 100ms heartbeat
        }

        // Wait a bit after last message for potential heartbeat
        await Task.Delay(50);

        engine.StopDeliveryLoop();

        // Data frames should have been sent
        dataFramesSent.Count.ShouldBeGreaterThan(0);

        // During continuous data delivery, idle heartbeats from the timer should
        // NOT have fired because the timer is reset on each data frame.
        // (The queue-based heartbeat frames still fire as part of Enqueue, but
        // the idle heartbeat timer counter should be 0 or very low since data
        // kept flowing within the heartbeat interval.)
        engine.IdleHeartbeatsSent.ShouldBe(0);
    }
}
