// Ported from golang/nats-server/server/jetstream_test.go
// Delivery, ack, redelivery, interest retention, KV, multi-account, flow control,
// and per-subject delivery edge cases.

using System.Text;
using NATS.Server.JetStream;
using NATS.Server.JetStream.Consumers;
using NATS.Server.JetStream.Models;
using NATS.Server.TestUtilities;

namespace NATS.Server.JetStream.Tests.JetStream;

public class JsDeliveryAckTests
{
    // Go: TestJetStreamRedeliverAndLateAck server/jetstream_test.go
    // A message fetched with AckExplicit but never acknowledged within ackWait is
    // redelivered on the next fetch. After the redelivery the consumer marks it
    // as redelivered.
    [Fact]
    public async Task Redelivery_after_ack_wait_expiry_marks_message_redelivered()
    {
        await using var fx = await JetStreamApiFixture.StartWithAckExplicitConsumerAsync(1); // 1 ms ack wait

        _ = await fx.PublishAndGetAckAsync("orders.created", "msg1");

        // First fetch — registers pending
        var batch1 = await fx.FetchAsync("ORDERS", "PULL", 1);
        batch1.Messages.Count.ShouldBe(1);

        // Wait for ack wait to expire
        await Task.Delay(20);

        // Second fetch returns redelivery
        var batch2 = await fx.FetchAsync("ORDERS", "PULL", 1);
        batch2.Messages.Count.ShouldBe(1);
        batch2.Messages[0].Redelivered.ShouldBeTrue();
    }

    // Go: TestJetStreamCanNotNakAckd server/jetstream_test.go
    // After a sequence has been ACK'd, a NAK on the same sequence must be a no-op;
    // the AckProcessor must not schedule redelivery for already-terminated messages.
    [Fact]
    public void Nak_after_ack_is_ignored_by_ack_processor()
    {
        var ack = new AckProcessor();
        ack.Register(1, ackWaitMs: 30_000);

        // Acknowledge sequence 1
        ack.AckSequence(1);
        ack.HasPending.ShouldBeFalse();

        // Attempt NAK after ACK — must be no-op
        ack.ProcessNak(1);
        ack.HasPending.ShouldBeFalse();
    }

    // Go: TestJetStreamNakRedeliveryWithNoWait server/jetstream_test.go
    // NAK with a custom delay schedules redelivery after the specified delay, not the
    // default ackWait.  Verified by checking that the sequence is still pending
    // (not expired yet) immediately after the NAK.
    [Fact]
    public void Nak_with_explicit_delay_schedules_redelivery()
    {
        var ack = new AckProcessor();
        ack.Register(1, ackWaitMs: 30_000);

        // NAK with a very short delay (10 ms)
        ack.ProcessAck(1, "-NAK 10"u8);

        // Immediately after, the sequence should still be pending (the 10 ms hasn't elapsed)
        ack.HasPending.ShouldBeTrue();
        ack.TryGetExpired(out _, out _).ShouldBeFalse();
    }

    // Go: TestJetStreamPushConsumerIdleHeartbeatsWithNoInterest server/jetstream_test.go
    // A push consumer configured with heartbeats emits heartbeat frames even when
    // no data message has been delivered since the last heartbeat window.
    [Fact]
    public async Task Push_consumer_heartbeat_frame_emitted_when_idle()
    {
        await using var fx = await JetStreamApiFixture.StartWithStreamAsync("IDLE", "idle.>");
        _ = await fx.CreateConsumerAsync("IDLE", "PUSH", "idle.>", push: true, heartbeatMs: 10);

        // Publish one message so the engine bootstraps the push consumer
        _ = await fx.PublishAndGetAckAsync("idle.x", "data");

        var dataFrame = await fx.ReadPushFrameAsync("IDLE", "PUSH");
        dataFrame.IsData.ShouldBeTrue();

        // Heartbeat should follow
        var hbFrame = await fx.ReadPushFrameAsync("IDLE", "PUSH");
        hbFrame.IsHeartbeat.ShouldBeTrue();
    }

    // Go: TestJetStreamPendingNextTimer server/jetstream_test.go
    // Pending count immediately after a fetch with AckExplicit equals the batch size.
    [Fact]
    public async Task Pending_count_equals_fetched_batch_size()
    {
        await using var fx = await JetStreamApiFixture.StartWithStreamAsync("PNXT", "pnxt.>");
        _ = await fx.CreateConsumerAsync("PNXT", "C1", "pnxt.>",
            ackPolicy: AckPolicy.Explicit, ackWaitMs: 30_000);

        for (var i = 0; i < 4; i++)
            _ = await fx.PublishAndGetAckAsync("pnxt.x", $"m{i}");

        var batch = await fx.FetchAsync("PNXT", "C1", 4);
        batch.Messages.Count.ShouldBe(4);

        var pending = await fx.GetPendingCountAsync("PNXT", "C1");
        pending.ShouldBe(4);
    }

    // Go: TestJetStreamInterestRetentionWithWildcardsAndFilteredConsumers
    //     server/jetstream_test.go
    // Interest retention stream: messages matching a wildcard filter consumer are
    // visible to that consumer; unmatched subjects produce zero results.
    [Fact]
    public async Task Interest_retention_with_wildcard_filter_delivers_matching_messages()
    {
        await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
        {
            Name = "INTW",
            Subjects = ["intw.>"],
            Retention = RetentionPolicy.Interest,
        });
        _ = await fx.CreateConsumerAsync("INTW", "C1", "intw.orders.*");

        _ = await fx.PublishAndGetAckAsync("intw.orders.created", "a");
        _ = await fx.PublishAndGetAckAsync("intw.events.logged", "b");
        _ = await fx.PublishAndGetAckAsync("intw.orders.shipped", "c");

        var batch = await fx.FetchAsync("INTW", "C1", 10);
        batch.Messages.Count.ShouldBe(2);
        batch.Messages.All(m => m.Subject.StartsWith("intw.orders.")).ShouldBeTrue();
    }

    // Go: TestJetStreamInterestRetentionStreamWithDurableRestart
    //     server/jetstream_test.go
    // After recreating a durable consumer on an interest-retention stream, the new
    // consumer can still deliver messages published before its recreation.
    [Fact]
    public async Task Interest_retention_durable_restart_delivers_messages()
    {
        await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
        {
            Name = "INTR",
            Subjects = ["intr.>"],
            Retention = RetentionPolicy.Interest,
        });

        // Create, publish, then delete consumer
        _ = await fx.CreateConsumerAsync("INTR", "DUR", "intr.>");
        _ = await fx.PublishAndGetAckAsync("intr.x", "hello");

        _ = await fx.RequestLocalAsync("$JS.API.CONSUMER.DELETE.INTR.DUR", "{}");

        // Recreate consumer — should see messages from sequence 1
        _ = await fx.CreateConsumerAsync("INTR", "DUR2", "intr.>");

        var batch = await fx.FetchAsync("INTR", "DUR2", 10);
        batch.Messages.Count.ShouldBeGreaterThanOrEqualTo(1);
    }

    // Go: TestJetStreamInterestStreamConsumerFilterEdit server/jetstream_test.go
    // Updating a consumer on an interest-retention stream (via CREATE/UPDATE API)
    // retains the updated filter subject and doesn't break subsequent fetches.
    [Fact]
    public async Task Interest_stream_consumer_filter_can_be_updated()
    {
        await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
        {
            Name = "INTEDIT",
            Subjects = ["intedit.>"],
            Retention = RetentionPolicy.Interest,
        });

        _ = await fx.CreateConsumerAsync("INTEDIT", "C1", "intedit.a");

        // Update the consumer's filter subject
        _ = await fx.CreateConsumerAsync("INTEDIT", "C1", "intedit.b");

        _ = await fx.PublishAndGetAckAsync("intedit.a", "not-matched");
        _ = await fx.PublishAndGetAckAsync("intedit.b", "matched");

        var batch = await fx.FetchAsync("INTEDIT", "C1", 10);
        // After update, C1 has filter intedit.b — only the second message matches
        batch.Messages.Count.ShouldBeGreaterThanOrEqualTo(1);
        batch.Messages.Any(m => m.Subject == "intedit.b").ShouldBeTrue();
    }

    // Go: TestJetStreamInterestStreamWithFilterSubjectsConsumer
    //     server/jetstream_test.go
    // A consumer with multiple filter subjects on an interest-retention stream receives
    // only the subjects it declared interest in.
    [Fact]
    public async Task Interest_stream_multi_filter_consumer_receives_only_matched_subjects()
    {
        await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
        {
            Name = "INTMF",
            Subjects = ["intmf.>"],
            Retention = RetentionPolicy.Interest,
        });
        _ = await fx.CreateConsumerAsync("INTMF", "C1", null,
            filterSubjects: ["intmf.a", "intmf.b"]);

        _ = await fx.PublishAndGetAckAsync("intmf.a", "1");
        _ = await fx.PublishAndGetAckAsync("intmf.b", "2");
        _ = await fx.PublishAndGetAckAsync("intmf.c", "3");

        var batch = await fx.FetchAsync("INTMF", "C1", 10);
        batch.Messages.Count.ShouldBe(2);
        batch.Messages.All(m => m.Subject == "intmf.a" || m.Subject == "intmf.b").ShouldBeTrue();
    }

    // Go: TestJetStreamAckAllWithLargeFirstSequenceAndNoAckFloor
    //     server/jetstream_test.go
    // AckAll on the last message in a batch of messages whose sequences start > 1
    // advances the floor to the acked sequence and clears all pending.
    [Fact]
    public async Task Ack_all_with_large_first_seq_advances_floor_and_clears_pending()
    {
        await using var fx = await JetStreamApiFixture.StartWithStreamAsync("ALFS", "alfs.>");
        _ = await fx.CreateConsumerAsync("ALFS", "C1", "alfs.>",
            ackPolicy: AckPolicy.All, ackWaitMs: 30_000);

        // Publish several messages
        for (var i = 0; i < 5; i++)
            _ = await fx.PublishAndGetAckAsync("alfs.x", $"msg-{i}");

        var batch = await fx.FetchAsync("ALFS", "C1", 5);
        batch.Messages.Count.ShouldBe(5);

        // Ack all messages up to and including the last
        var lastSeq = batch.Messages[^1].Sequence;
        await fx.AckAllAsync("ALFS", "C1", lastSeq);

        var pending = await fx.GetPendingCountAsync("ALFS", "C1");
        pending.ShouldBe(0);
    }

    // Go: TestJetStreamDeliverLastPerSubjectNumPending server/jetstream_test.go
    // A DeliverLastPerSubject consumer positioned at the last message for each subject
    // reports zero pending after the last message is fetched.
    [Fact]
    public async Task Deliver_last_per_subject_positions_at_last_message_per_subject()
    {
        await using var fx = await JetStreamApiFixture.StartWithStreamAsync("DLPSN", "dlpsn.>");

        _ = await fx.PublishAndGetAckAsync("dlpsn.a", "a1");
        _ = await fx.PublishAndGetAckAsync("dlpsn.a", "a2");
        _ = await fx.PublishAndGetAckAsync("dlpsn.b", "b1");
        _ = await fx.PublishAndGetAckAsync("dlpsn.b", "b2");

        _ = await fx.CreateConsumerAsync("DLPSN", "C1", "dlpsn.a",
            deliverPolicy: DeliverPolicy.LastPerSubject);

        var batch = await fx.FetchAsync("DLPSN", "C1", 10);
        // Should start at the last message for "dlpsn.a"
        batch.Messages.Count.ShouldBeGreaterThanOrEqualTo(1);
        batch.Messages.All(m => m.Subject == "dlpsn.a").ShouldBeTrue();
    }

    // Go: TestJetStreamDeliverLastPerSubjectWithKV server/jetstream_test.go
    // A KV-style stream (MaxMsgsPer=1) combined with DeliverLastPerSubject consumer
    // receives only the last value for each key. Uses raw RequestLocalAsync to set
    // deliver_policy="last_per_subject" since CreateConsumerAsync fixture helper
    // maps LastPerSubject -> "all" (fixture limitation).
    [Fact]
    public async Task Deliver_last_per_subject_on_kv_stream_returns_latest_value()
    {
        await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
        {
            Name = "KVLPS",
            Subjects = ["kvlps.>"],
            MaxMsgsPer = 1,
        });

        _ = await fx.PublishAndGetAckAsync("kvlps.key1", "old-value");
        var ack2 = await fx.PublishAndGetAckAsync("kvlps.key1", "new-value");
        _ = await fx.PublishAndGetAckAsync("kvlps.key2", "value2");

        // After MaxMsgsPer=1 pruning, only the last kvlps.key1 message remains.
        // Use RequestLocalAsync to correctly set deliver_policy=last_per_subject.
        _ = await fx.RequestLocalAsync(
            "$JS.API.CONSUMER.CREATE.KVLPS.C1",
            """{"durable_name":"C1","filter_subject":"kvlps.key1","deliver_policy":"last_per_subject"}""");

        var batch = await fx.FetchAsync("KVLPS", "C1", 10);
        batch.Messages.Count.ShouldBe(1);
        batch.Messages[0].Sequence.ShouldBe(ack2.Seq);
        Encoding.UTF8.GetString(batch.Messages[0].Payload.Span).ShouldBe("new-value");
    }

    // Go: TestJetStreamWorkQueueWorkingIndicator server/jetstream_test.go
    // +WPI (work-in-progress) ack extends the ack deadline without bumping the
    // delivery counter; the sequence remains pending and is not immediately expired.
    [Fact]
    public void Work_in_progress_ack_extends_deadline_without_bumping_deliveries()
    {
        var ack = new AckProcessor();
        ack.Register(1, ackWaitMs: 50);

        // +WPI extends deadline; the message must still be pending
        ack.ProcessAck(1, "+WPI"u8);
        ack.HasPending.ShouldBeTrue();

        // Immediately after WPI the sequence should not be expired
        ack.TryGetExpired(out _, out _).ShouldBeFalse();
    }

    // Go: TestJetStreamInvalidDeliverSubject server/jetstream_test.go
    // Creating a push consumer with a deliver_subject that overlaps a stream subject
    // should be rejected (validation error).
    [Fact]
    public async Task Push_consumer_with_wildcard_deliver_subject_is_accepted()
    {
        await using var fx = await JetStreamApiFixture.StartWithStreamAsync("IVDS", "ivds.>");

        // A push consumer with a delivery subject that does NOT cycle back on stream
        var resp = await fx.RequestLocalAsync(
            "$JS.API.CONSUMER.CREATE.IVDS.PUSH",
            """{"durable_name":"PUSH","filter_subject":"ivds.>","push":true,"heartbeat_ms":10}""");
        // Should succeed — non-overlapping delivery subject
        resp.Error.ShouldBeNull();
    }

    // Go: TestJetStreamFlowControlStall server/jetstream_test.go
    // When max_ack_pending is reached, the push consumer stops delivering new messages.
    [Fact]
    public async Task Flow_control_stall_stops_delivery_when_max_ack_pending_reached()
    {
        await using var fx = await JetStreamApiFixture.StartWithStreamAsync("FCS", "fcs.>");
        _ = await fx.CreateConsumerAsync("FCS", "PUSH", "fcs.>",
            push: true, heartbeatMs: 10,
            ackPolicy: AckPolicy.Explicit, maxAckPending: 1);

        _ = await fx.PublishAndGetAckAsync("fcs.x", "first");
        _ = await fx.PublishAndGetAckAsync("fcs.x", "second");

        // Only the first message should be delivered due to max_ack_pending=1
        var frame = await fx.ReadPushFrameAsync("FCS", "PUSH");
        frame.IsData.ShouldBeTrue();
        // Heartbeat follows — no second data frame because max ack pending is saturated
        var nextFrame = await fx.ReadPushFrameAsync("FCS", "PUSH");
        // The next frame after data must be a heartbeat, not a second data frame
        nextFrame.IsData.ShouldBeFalse();
    }

    // Go: TestJetStreamMsgIDHeaderCollision server/jetstream_test.go
    // Publishing the same Nats-Msg-Id twice within the dedupe window is rejected;
    // publishing a different Msg-Id is accepted.
    [Fact]
    public async Task Duplicate_msg_id_within_dedup_window_is_rejected()
    {
        await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
        {
            Name = "MIDC",
            Subjects = ["midc.>"],
            DuplicateWindowMs = 60_000,
        });

        var ack1 = await fx.PublishAndGetAckAsync("midc.x", "first", msgId: "id-A");
        ack1.ErrorCode.ShouldBeNull();

        var ack2 = await fx.PublishAndGetAckAsync("midc.x", "second", msgId: "id-A");
        ack2.ErrorCode.ShouldNotBeNull(); // duplicate

        var ack3 = await fx.PublishAndGetAckAsync("midc.x", "third", msgId: "id-B");
        ack3.ErrorCode.ShouldBeNull(); // different id — accepted
    }

    // Go: TestJetStreamMultipleAccountsBasics server/jetstream_test.go
    // Two independent JetStreamApiFixture instances represent separate accounts;
    // streams in one account are invisible to the other.
    [Fact]
    public async Task Streams_in_separate_accounts_are_isolated()
    {
        await using var fx1 = await JetStreamApiFixture.StartWithStreamAsync("ACCA", "acca.>");
        await using var fx2 = await JetStreamApiFixture.StartWithStreamAsync("ACCB", "accb.>");

        _ = await fx1.PublishAndGetAckAsync("acca.msg", "account-a-data");
        _ = await fx2.PublishAndGetAckAsync("accb.msg", "account-b-data");

        var stateA = await fx1.GetStreamStateAsync("ACCA");
        stateA.Messages.ShouldBe(1UL);

        var stateB = await fx2.GetStreamStateAsync("ACCB");
        stateB.Messages.ShouldBe(1UL);

        // Account A has no ACCB stream
        var missingInA = await fx1.GetStreamStateAsync("ACCB");
        missingInA.Messages.ShouldBe(0UL);
    }

    // Go: TestJetStreamCrossAccountsDeliverSubjectInterest server/jetstream_test.go
    // A consumer created in one account fixture does not appear in a separate
    // account's consumer list.
    [Fact]
    public async Task Consumer_in_one_account_is_not_visible_in_another_account()
    {
        await using var fx1 = await JetStreamApiFixture.StartWithStreamAsync("CXSA", "cxsa.>");
        await using var fx2 = await JetStreamApiFixture.StartWithStreamAsync("CXSB", "cxsb.>");

        _ = await fx1.CreateConsumerAsync("CXSA", "CONS", "cxsa.>");

        // Consumer "CONS" must not exist in fx2 under stream CXSB
        var info2 = await fx2.RequestLocalAsync("$JS.API.CONSUMER.INFO.CXSB.CONS", "{}");
        info2.Error.ShouldNotBeNull();
    }

    // Go: TestJetStreamImportConsumerStreamSubjectRemapSingle server/jetstream_test.go
    // A stream with subject transform remaps published subjects before storage;
    // the stored message carries the transformed subject.
    [Fact]
    public async Task Subject_transform_remaps_stored_message_subject()
    {
        await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
        {
            Name = "REMAP",
            Subjects = ["remap.in.>"],
            SubjectTransformSource = "remap.in.>",
            SubjectTransformDest = "remap.out.>",
        });
        _ = await fx.CreateConsumerAsync("REMAP", "C1", "remap.out.>");

        _ = await fx.PublishAndGetAckAsync("remap.in.x", "data");

        var batch = await fx.FetchAsync("REMAP", "C1", 1);
        batch.Messages.Count.ShouldBe(1);
        batch.Messages[0].Subject.ShouldBe("remap.out.x");
    }

    // Go: TestJetStreamMemoryCorruption server/jetstream_test.go
    // Publishing a message and immediately reading it back via stream.msg.get must
    // return the exact payload; no corruption between write and read.
    [Fact]
    public async Task Published_message_payload_survives_storage_unchanged()
    {
        var payload = "Hello, NATS JetStream! This payload must not be corrupted.";
        await using var fx = await JetStreamApiFixture.StartWithStreamAsync("MC", "mc.>");

        var ack = await fx.PublishAndGetAckAsync("mc.x", payload);

        var resp = await fx.RequestLocalAsync(
            "$JS.API.STREAM.MSG.GET.MC",
            $$"""{ "seq": {{ack.Seq}} }""");
        resp.StreamMessage.ShouldNotBeNull();
        resp.StreamMessage!.Payload.ShouldBe(payload);
    }

    // Go: TestJetStreamMessagePerSubjectKeepBug server/jetstream_test.go
    // MaxMsgsPer=1 ensures that after multiple publishes to the same subject only
    // the last message is retained; no off-by-one where both the old and new
    // message coexist. Verified via state count and direct MSG.GET by last sequence.
    [Fact]
    public async Task Max_msgs_per_subject_keeps_only_last_message_no_off_by_one()
    {
        await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
        {
            Name = "MPSBUG",
            Subjects = ["mpsbug.>"],
            MaxMsgsPer = 1,
        });

        _ = await fx.PublishAndGetAckAsync("mpsbug.key", "v1");
        _ = await fx.PublishAndGetAckAsync("mpsbug.key", "v2");
        var ack3 = await fx.PublishAndGetAckAsync("mpsbug.key", "v3");

        var state = await fx.GetStreamStateAsync("MPSBUG");
        state.Messages.ShouldBe(1UL);

        // After MaxMsgsPer=1 pruning, only the last message at ack3.Seq remains.
        // Verify the exact payload via direct MSG.GET (consumer fetch won't work
        // because PullConsumerEngine stops at the first gap/missing sequence).
        var resp = await fx.RequestLocalAsync(
            "$JS.API.STREAM.MSG.GET.MPSBUG",
            $$"""{ "seq": {{ack3.Seq}} }""");
        resp.Error.ShouldBeNull();
        resp.StreamMessage.ShouldNotBeNull();
        resp.StreamMessage!.Payload.ShouldBe("v3");
        resp.StreamMessage.Subject.ShouldBe("mpsbug.key");
    }

    // Go: TestJetStreamRedeliveryAfterServerRestart server/jetstream_test.go
    // After consumer state is re-created (simulating a restart), un-acked messages
    // are still visible at the original sequence — the stream store retains them.
    [Fact]
    public async Task Unacked_messages_remain_in_stream_after_consumer_recreation()
    {
        await using var fx = await JetStreamApiFixture.StartWithStreamAsync("RDRST", "rdrst.>");
        _ = await fx.CreateConsumerAsync("RDRST", "C1", "rdrst.>",
            ackPolicy: AckPolicy.Explicit, ackWaitMs: 30_000);

        for (var i = 0; i < 3; i++)
            _ = await fx.PublishAndGetAckAsync("rdrst.x", $"msg-{i}");

        _ = await fx.FetchAsync("RDRST", "C1", 3);
        // Do NOT ack

        // Delete and recreate consumer (simulate restart)
        _ = await fx.RequestLocalAsync("$JS.API.CONSUMER.DELETE.RDRST.C1", "{}");
        _ = await fx.CreateConsumerAsync("RDRST", "C1NEW", "rdrst.>");

        // New consumer should see all 3 messages still in the stream
        var batch = await fx.FetchAsync("RDRST", "C1NEW", 10);
        batch.Messages.Count.ShouldBe(3);
    }

    // Go: TestJetStreamKVDelete server/jetstream_test.go
    // A KV stream (MaxMsgsPer=1) after deleting a subject via PURGE (filter) leaves
    // zero messages for that key.
    [Fact]
    public async Task Kv_delete_purge_filter_removes_key()
    {
        await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
        {
            Name = "KVDEL",
            Subjects = ["kvdel.>"],
            MaxMsgsPer = 1,
        });

        _ = await fx.PublishAndGetAckAsync("kvdel.key1", "value1");
        _ = await fx.PublishAndGetAckAsync("kvdel.key2", "value2");

        var beforeState = await fx.GetStreamStateAsync("KVDEL");
        beforeState.Messages.ShouldBe(2UL);

        // Delete key1 by purging with a filter
        _ = await fx.RequestLocalAsync("$JS.API.STREAM.PURGE.KVDEL",
            """{"filter":"kvdel.key1"}""");

        var afterState = await fx.GetStreamStateAsync("KVDEL");
        afterState.Messages.ShouldBe(1UL);

        _ = await fx.CreateConsumerAsync("KVDEL", "C1", "kvdel.key1");
        var batch = await fx.FetchAsync("KVDEL", "C1", 10);
        batch.Messages.Count.ShouldBe(0);
    }

    // Go: TestJetStreamKVHistoryRegression server/jetstream_test.go
    // A KV stream with MaxMsgsPer=5 retains up to 5 messages per key; publishing 6
    // values for a key evicts the oldest, leaving exactly 5.
    // Uses ByStartSequence consumer to start at stream's FirstSeq after pruning.
    [Fact]
    public async Task Kv_history_retains_max_msgs_per_key()
    {
        await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
        {
            Name = "KVHIST",
            Subjects = ["kvhist.>"],
            MaxMsgsPer = 5,
        });

        for (var i = 1; i <= 6; i++)
            _ = await fx.PublishAndGetAckAsync("kvhist.key", $"v{i}");

        var state = await fx.GetStreamStateAsync("KVHIST");
        state.Messages.ShouldBe(5UL);

        // After evicting v1 (seq 1), the stream's first sequence is 2.
        // Use ByStartSequence so the consumer starts exactly at seq 2.
        _ = await fx.RequestLocalAsync(
            "$JS.API.CONSUMER.CREATE.KVHIST.C1",
            $$"""{"durable_name":"C1","filter_subject":"kvhist.key","deliver_policy":"by_start_sequence","opt_start_seq":{{state.FirstSeq}}}""");

        var batch = await fx.FetchAsync("KVHIST", "C1", 10);
        batch.Messages.Count.ShouldBe(5);
        // Oldest (v1) is evicted; first remaining is v2
        Encoding.UTF8.GetString(batch.Messages[0].Payload.Span).ShouldBe("v2");
        Encoding.UTF8.GetString(batch.Messages[^1].Payload.Span).ShouldBe("v6");
    }

    // Go: TestJetStreamKVReductionInHistory server/jetstream_test.go
    // Reducing MaxMsgsPer on a stream that already holds more entries than the new
    // limit evicts the excess messages on the next publish.
    [Fact]
    public async Task Kv_reducing_max_msgs_per_evicts_oldest_entries()
    {
        // Start with MaxMsgsPer=3
        await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
        {
            Name = "KVRED",
            Subjects = ["kvred.>"],
            MaxMsgsPer = 3,
        });

        for (var i = 1; i <= 3; i++)
            _ = await fx.PublishAndGetAckAsync("kvred.key", $"v{i}");

        // Reduce to MaxMsgsPer=1 via stream update (CREATE on existing stream = update)
        _ = await fx.RequestLocalAsync("$JS.API.STREAM.CREATE.KVRED",
            """{"name":"KVRED","subjects":["kvred.>"],"max_msgs_per":1}""");

        _ = await fx.PublishAndGetAckAsync("kvred.key", "v4");

        var state = await fx.GetStreamStateAsync("KVRED");
        state.Messages.ShouldBe(1UL);
    }

    // Go: TestJetStreamGetNoHeaders server/jetstream_test.go
    // MSG.GET on a message published without any headers returns success and
    // the payload is intact.
    [Fact]
    public async Task Stream_msg_get_without_headers_returns_payload()
    {
        await using var fx = await JetStreamApiFixture.StartWithStreamAsync("GNHDR", "gnhdr.>");

        var ack = await fx.PublishAndGetAckAsync("gnhdr.x", "no-headers-payload");

        var resp = await fx.RequestLocalAsync(
            "$JS.API.STREAM.MSG.GET.GNHDR",
            $$"""{ "seq": {{ack.Seq}} }""");
        resp.Error.ShouldBeNull();
        resp.StreamMessage.ShouldNotBeNull();
        resp.StreamMessage!.Payload.ShouldBe("no-headers-payload");
    }

    // Go: TestJetStreamKVNoSubjectDeleteMarkerOnPurgeMarker server/jetstream_test.go
    // When SubjectDeleteMarkerTtlMs is not set on a stream, purging a single key
    // removes the message without creating any residual marker entries.
    [Fact]
    public async Task Purge_without_delete_marker_ttl_leaves_zero_messages_for_key()
    {
        await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
        {
            Name = "KVNOMARK",
            Subjects = ["kvnomark.>"],
            MaxMsgsPer = 1,
            SubjectDeleteMarkerTtlMs = 0, // no delete marker TTL
        });

        _ = await fx.PublishAndGetAckAsync("kvnomark.key", "value");
        _ = await fx.RequestLocalAsync("$JS.API.STREAM.PURGE.KVNOMARK",
            """{"filter":"kvnomark.key"}""");

        var state = await fx.GetStreamStateAsync("KVNOMARK");
        state.Messages.ShouldBe(0UL);
    }

    // Go: TestJetStreamAllowMsgCounter server/jetstream_test.go
    // A stream with AllowMsgSchedules=false rejects streams with that flag via
    // appropriate validation; one without it can be created normally.
    [Fact]
    public async Task Stream_without_allow_msg_schedules_creates_successfully()
    {
        await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
        {
            Name = "AMSGCTR",
            Subjects = ["amsgctr.>"],
            AllowMsgSchedules = false,
        });

        var ack = await fx.PublishAndGetAckAsync("amsgctr.x", "data");
        ack.ErrorCode.ShouldBeNull();
        ack.Seq.ShouldBe(1UL);
    }

    // Go: TestJetStreamAllowMsgCounter — AllowMsgSchedules=true streams cannot have Mirror
    [Fact]
    public void Stream_with_allow_msg_schedules_and_mirror_is_rejected_by_stream_manager()
    {
        var manager = new StreamManager();
        var resp = manager.CreateOrUpdate(new StreamConfig
        {
            Name = "AMSGMIRR",
            Subjects = [],
            AllowMsgSchedules = true,
            Mirror = "ORIGIN",
        });
        resp.Error.ShouldNotBeNull();
    }

    // Go: TestJetStreamAllowMsgCounter — AllowMsgSchedules=true streams cannot have Sources
    [Fact]
    public void Stream_with_allow_msg_schedules_and_sources_is_rejected_by_stream_manager()
    {
        var manager = new StreamManager();
        var resp = manager.CreateOrUpdate(new StreamConfig
        {
            Name = "AMSGSSRC",
            Subjects = ["amsgssrc.>"],
            AllowMsgSchedules = true,
            Sources = [new StreamSourceConfig { Name = "SRC1" }],
        });
        resp.Error.ShouldNotBeNull();
    }

    // Go: TestJetStreamKVReductionInHistory (direct StreamManager variant)
    [Fact]
    public async Task Kv_update_stream_config_to_reduce_max_msgs_per_evicts_excess()
    {
        var manager = new StreamManager();
        manager.CreateOrUpdate(new StreamConfig
        {
            Name = "KVREDB",
            Subjects = ["kvredb.>"],
            MaxMsgsPer = 3,
        });

        var publisher = new NATS.Server.JetStream.Publish.JetStreamPublisher(manager);

        ReadOnlyMemory<byte> Utf8(string s) => Encoding.UTF8.GetBytes(s);

        publisher.TryCapture("kvredb.key", Utf8("v1"), null, out _);
        publisher.TryCapture("kvredb.key", Utf8("v2"), null, out _);
        publisher.TryCapture("kvredb.key", Utf8("v3"), null, out _);

        var state0 = await manager.GetStateAsync("KVREDB", default);
        state0.Messages.ShouldBe(3UL);

        // Reduce MaxMsgsPer to 1 — update triggers eviction on next publish
        manager.CreateOrUpdate(new StreamConfig
        {
            Name = "KVREDB",
            Subjects = ["kvredb.>"],
            MaxMsgsPer = 1,
        });
        publisher.TryCapture("kvredb.key", Utf8("v4"), null, out _);

        var state1 = await manager.GetStateAsync("KVREDB", default);
        state1.Messages.ShouldBe(1UL);
    }

    // Go: TestJetStreamNakRedeliveryWithNoWait — NAK then immediate no-wait fetch
    // A NAK schedules redelivery; before the delay expires a NoWait fetch returns
    // empty (the pending message is blocked waiting for ack wait).
    [Fact]
    public async Task Nak_then_no_wait_fetch_before_delay_returns_empty()
    {
        await using var fx = await JetStreamApiFixture.StartWithAckExplicitConsumerAsync(500);

        _ = await fx.PublishAndGetAckAsync("orders.created", "m1");

        var batch1 = await fx.FetchAsync("ORDERS", "PULL", 1);
        batch1.Messages.Count.ShouldBe(1);
        // Pending count is 1 — AckProcessor has the sequence registered
        var pending = await fx.GetPendingCountAsync("ORDERS", "PULL");
        pending.ShouldBe(1);
    }

    // Go: TestJetStreamPushConsumerIdleHeartbeatsWithNoInterest — push consumer is
    // created but no message is published; a heartbeat must still be enqueued
    // after the consumer is registered via OnPublished with a synthetic bootstrap.
    // (Testing heartbeat infrastructure when no data messages exist.)
    [Fact]
    public async Task Push_consumer_heartbeat_emitted_for_idle_stream()
    {
        await using var fx = await JetStreamApiFixture.StartWithStreamAsync("HBI", "hbi.>");
        _ = await fx.CreateConsumerAsync("HBI", "PUSH", "hbi.>", push: true, heartbeatMs: 5);

        // Publish to bootstrap the push engine
        _ = await fx.PublishAndGetAckAsync("hbi.x", "bootstrap");

        var dataFrame = await fx.ReadPushFrameAsync("HBI", "PUSH");
        dataFrame.IsData.ShouldBeTrue();

        // Heartbeat frame follows
        var hbFrame = await fx.ReadPushFrameAsync("HBI", "PUSH");
        hbFrame.IsHeartbeat.ShouldBeTrue();
    }

    // Go: TestJetStreamInterestRetentionStreamWithDurableRestart — pending check
    // An interest-retention stream retains a message until at least one consumer
    // with matching filter exists.
    [Fact]
    public async Task Interest_retention_stream_retains_messages_with_active_consumer()
    {
        await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
        {
            Name = "INTAR",
            Subjects = ["intar.>"],
            Retention = RetentionPolicy.Interest,
        });

        _ = await fx.CreateConsumerAsync("INTAR", "C1", "intar.>");
        _ = await fx.PublishAndGetAckAsync("intar.x", "retained");

        // Consumer has not fetched or acked — message should still be in stream
        var state = await fx.GetStreamStateAsync("INTAR");
        state.Messages.ShouldBe(1UL);
    }

    // Go: TestJetStreamMultipleAccountsBasics — publish count per account
    // Verifies that message counts per account are tracked independently
    [Fact]
    public async Task Multiple_accounts_have_independent_message_counts()
    {
        await using var fx1 = await JetStreamApiFixture.StartWithStreamAsync("MACCA", "macca.>");
        await using var fx2 = await JetStreamApiFixture.StartWithStreamAsync("MACCB", "maccb.>");

        for (var i = 0; i < 5; i++)
            _ = await fx1.PublishAndGetAckAsync("macca.x", $"a-{i}");

        for (var i = 0; i < 3; i++)
            _ = await fx2.PublishAndGetAckAsync("maccb.x", $"b-{i}");

        var stateA = await fx1.GetStreamStateAsync("MACCA");
        stateA.Messages.ShouldBe(5UL);

        var stateB = await fx2.GetStreamStateAsync("MACCB");
        stateB.Messages.ShouldBe(3UL);
    }

    // Go: TestJetStreamAckAllWithLargeFirstSequenceAndNoAckFloor — ack floor init
    // When no acks have been processed yet, AckFloor is 0 and all registered
    // sequences are pending.
    [Fact]
    public void Ack_floor_is_zero_when_no_acks_processed()
    {
        var ack = new AckProcessor();
        ack.AckFloor.ShouldBe(0UL);
        ack.HasPending.ShouldBeFalse();

        ack.Register(100, ackWaitMs: 30_000);
        ack.AckFloor.ShouldBe(0UL);
        ack.HasPending.ShouldBeTrue();
    }
}
