// Ported from golang/nats-server/server/jetstream_test.go
// Covers JetStream configuration, resource limits, and validation tests focused on
// config normalization, placement, tiered limits, resource enforcement, stream creation
// validation, subject delete markers, and filter semantics.

using NATS.Server.Auth;
using NATS.Server.Configuration;
using NATS.Server.JetStream;
using NATS.Server.JetStream.Api;
using NATS.Server.JetStream.Models;
using NATS.Server.JetStream.Validation;
using NATS.Server.TestUtilities;

namespace NATS.Server.JetStream.Tests.JetStream;

/// <summary>
/// Go parity tests ported from jetstream_test.go covering config/limits/validation
/// scenarios: auto-tune FS config, domain config, resource config, system limits,
/// tiered limits, disabled limits, would-exceed checks, storage reservation,
/// usage tracking, stream creation pedantic/strict mode, subject mapping validation,
/// subject delete markers, filter-all semantics, and cleanup threshold behavior.
/// </summary>
public class JsConfigLimitsTests
{
    // =========================================================================
    // TestJetStreamAutoTuneFSConfig — jetstream_test.go
    // JetStreamService started with a StoreDir runs in file-backed mode;
    // MaxMemory and MaxStore expose the configured resource limits.
    // =========================================================================

    [Fact]
    public async Task AutoTuneFSConfig_store_dir_and_limits_are_reflected()
    {
        // Go: TestJetStreamAutoTuneFSConfig jetstream_test.go
        var storeDir = Path.Combine(Path.GetTempPath(), "nats-js-autotune-" + Guid.NewGuid().ToString("N"));
        try
        {
            var options = new JetStreamOptions
            {
                StoreDir = storeDir,
                MaxMemoryStore = 64 * 1024 * 1024L,  // 64 MiB
                MaxFileStore = 512 * 1024 * 1024L,   // 512 MiB
            };
            await using var svc = new JetStreamService(options);
            await svc.StartAsync(CancellationToken.None);

            svc.IsRunning.ShouldBeTrue();
            svc.MaxMemory.ShouldBe(64 * 1024 * 1024L);
            svc.MaxStore.ShouldBe(512 * 1024 * 1024L);
            Directory.Exists(storeDir).ShouldBeTrue();
        }
        finally
        {
            if (Directory.Exists(storeDir))
                Directory.Delete(storeDir, recursive: true);
        }
    }

    // =========================================================================
    // TestJetStreamServerDomainConfig — jetstream_test.go
    // JetStreamService exposes config limits regardless of StoreDir. When no
    // StoreDir is set, memory-only mode starts correctly.
    // =========================================================================

    [Fact]
    public async Task ServerDomainConfig_memory_only_mode_starts_without_store_dir()
    {
        // Go: TestJetStreamServerDomainConfig jetstream_test.go
        var options = new JetStreamOptions
        {
            StoreDir = string.Empty,
            MaxMemoryStore = 256 * 1024 * 1024L, // 256 MiB
        };
        await using var svc = new JetStreamService(options);
        await svc.StartAsync(CancellationToken.None);

        svc.IsRunning.ShouldBeTrue();
        svc.MaxMemory.ShouldBe(256 * 1024 * 1024L);
        svc.MaxStore.ShouldBe(0L); // no file store configured
    }

    // =========================================================================
    // TestJetStreamServerResourcesConfig — jetstream_test.go
    // Configuration values for max streams, max consumers, max memory, and max
    // file store are all exposed correctly through JetStreamService properties.
    // =========================================================================

    [Fact]
    public async Task ServerResourcesConfig_all_limits_exposed_correctly()
    {
        // Go: TestJetStreamServerResourcesConfig jetstream_test.go
        var options = new JetStreamOptions
        {
            MaxStreams = 100,
            MaxConsumers = 1000,
            MaxMemoryStore = 1_073_741_824L,  // 1 GiB
            MaxFileStore = 10_737_418_240L,   // 10 GiB
        };
        await using var svc = new JetStreamService(options);
        await svc.StartAsync(CancellationToken.None);

        svc.MaxStreams.ShouldBe(100);
        svc.MaxConsumers.ShouldBe(1000);
        svc.MaxMemory.ShouldBe(1_073_741_824L);
        svc.MaxStore.ShouldBe(10_737_418_240L);
    }

    [Fact]
    public void ServerResourcesConfig_default_values_are_zero_unlimited()
    {
        // Go: TestJetStreamServerResourcesConfig — default unlimited config
        var options = new JetStreamOptions();
        var svc = new JetStreamService(options);

        svc.MaxStreams.ShouldBe(0);
        svc.MaxConsumers.ShouldBe(0);
        svc.MaxMemory.ShouldBe(0L);
        svc.MaxStore.ShouldBe(0L);
    }

    // =========================================================================
    // TestJetStreamSystemLimitsPlacement — jetstream_test.go
    // Account-level stream limits prevent creation beyond the configured maximum.
    // Tests the placement/enforcement path in StreamManager when an Account is
    // provided.
    // =========================================================================

    [Fact]
    public async Task SystemLimitsPlacement_account_limits_max_streams_enforced()
    {
        // Go: TestJetStreamSystemLimitsPlacement jetstream_test.go
        await using var fx = await JetStreamApiFixture.StartJwtLimitedAccountAsync(maxStreams: 2);

        var s1 = await fx.RequestLocalAsync(
            "$JS.API.STREAM.CREATE.PLACE1",
            """{"name":"PLACE1","subjects":["place1.>"]}""");
        s1.Error.ShouldBeNull();

        var s2 = await fx.RequestLocalAsync(
            "$JS.API.STREAM.CREATE.PLACE2",
            """{"name":"PLACE2","subjects":["place2.>"]}""");
        s2.Error.ShouldBeNull();

        var s3 = await fx.RequestLocalAsync(
            "$JS.API.STREAM.CREATE.PLACE3",
            """{"name":"PLACE3","subjects":["place3.>"]}""");
        s3.Error.ShouldNotBeNull();
        s3.Error!.Code.ShouldBe(10027);
    }

    [Fact]
    public async Task SystemLimitsPlacement_delete_frees_stream_slot_for_reuse()
    {
        // Go: TestJetStreamSystemLimitsPlacement — slot freed after delete
        await using var fx = await JetStreamApiFixture.StartJwtLimitedAccountAsync(maxStreams: 1);

        var s1 = await fx.RequestLocalAsync(
            "$JS.API.STREAM.CREATE.SLOTFREE",
            """{"name":"SLOTFREE","subjects":["slotfree.>"]}""");
        s1.Error.ShouldBeNull();

        var del = await fx.RequestLocalAsync("$JS.API.STREAM.DELETE.SLOTFREE", "{}");
        del.Success.ShouldBeTrue();

        var s2 = await fx.RequestLocalAsync(
            "$JS.API.STREAM.CREATE.SLOTFREE2",
            """{"name":"SLOTFREE2","subjects":["slotfree2.>"]}""");
        s2.Error.ShouldBeNull();
    }

    // =========================================================================
    // TestJetStreamTieredLimits — jetstream_test.go
    // AccountLimits MaxStreams and MaxConsumers are the authoritative tier limits.
    // When JetStreamLimits.MaxStreams is set on an Account it takes precedence over
    // MaxJetStreamStreams.
    // =========================================================================

    [Fact]
    public void TieredLimits_account_limits_max_streams_takes_precedence()
    {
        // Go: TestJetStreamTieredLimits jetstream_test.go
        // When JetStreamLimits.MaxStreams is set it overrides MaxJetStreamStreams.
        var account = new Account("TIERED")
        {
            MaxJetStreamStreams = 100, // legacy field
            JetStreamLimits = new AccountLimits { MaxStreams = 2 }, // authoritative
        };

        account.TryReserveStream().ShouldBeTrue();
        account.TryReserveStream().ShouldBeTrue();
        account.TryReserveStream().ShouldBeFalse(); // tiered limit of 2 hit
    }

    [Fact]
    public void TieredLimits_max_consumers_enforced_via_account_limits()
    {
        // Go: TestJetStreamTieredLimits — consumer tier limit
        var account = new Account("TIERED_CON")
        {
            JetStreamLimits = new AccountLimits { MaxConsumers = 3 },
        };

        account.TryReserveConsumer().ShouldBeTrue();
        account.TryReserveConsumer().ShouldBeTrue();
        account.TryReserveConsumer().ShouldBeTrue();
        account.TryReserveConsumer().ShouldBeFalse(); // tiered limit of 3 hit
    }

    [Fact]
    public void TieredLimits_release_consumer_frees_slot()
    {
        // Go: TestJetStreamTieredLimits — release consumer restores slot
        var account = new Account("TIERED_REL")
        {
            JetStreamLimits = new AccountLimits { MaxConsumers = 1 },
        };

        account.TryReserveConsumer().ShouldBeTrue();
        account.TryReserveConsumer().ShouldBeFalse();

        account.ReleaseConsumer();

        account.TryReserveConsumer().ShouldBeTrue();
    }

    // =========================================================================
    // TestJetStreamDisabledLimitsEnforcement — jetstream_test.go
    // When account limits are zero (unlimited), no limit enforcement occurs.
    // =========================================================================

    [Fact]
    public void DisabledLimitsEnforcement_zero_max_streams_allows_many()
    {
        // Go: TestJetStreamDisabledLimitsEnforcement jetstream_test.go
        var account = new Account("NOLIMIT")
        {
            MaxJetStreamStreams = 0, // unlimited
            JetStreamLimits = AccountLimits.Unlimited,
        };

        for (var i = 0; i < 50; i++)
            account.TryReserveStream().ShouldBeTrue();
    }

    [Fact]
    public void DisabledLimitsEnforcement_zero_max_consumers_allows_many()
    {
        // Go: TestJetStreamDisabledLimitsEnforcement — unlimited consumers
        var account = new Account("NOCONLIMIT")
        {
            JetStreamLimits = AccountLimits.Unlimited,
        };

        for (var i = 0; i < 50; i++)
            account.TryReserveConsumer().ShouldBeTrue();
    }

    // =========================================================================
    // TestJetStreamWouldExceedLimits — jetstream_test.go
    // The account stream count correctly reflects additions and the max limit
    // blocks further reservations at the boundary.
    // =========================================================================

    [Fact]
    public void WouldExceedLimits_stream_count_increments_and_blocks_at_max()
    {
        // Go: TestJetStreamWouldExceedLimits jetstream_test.go
        var account = new Account("EXCEED")
        {
            MaxJetStreamStreams = 3,
        };

        account.JetStreamStreamCount.ShouldBe(0);

        account.TryReserveStream().ShouldBeTrue();
        account.JetStreamStreamCount.ShouldBe(1);

        account.TryReserveStream().ShouldBeTrue();
        account.JetStreamStreamCount.ShouldBe(2);

        account.TryReserveStream().ShouldBeTrue();
        account.JetStreamStreamCount.ShouldBe(3);

        // At the limit — next reservation must fail
        account.TryReserveStream().ShouldBeFalse();
        account.JetStreamStreamCount.ShouldBe(3); // count unchanged after failed reserve
    }

    // =========================================================================
    // TestJetStreamStorageReservedBytes — jetstream_test.go
    // AccountLimits.MaxStorage is enforced via TrackStorageDelta.
    // =========================================================================

    [Fact]
    public void StorageReservedBytes_max_storage_prevents_exceeding_limit()
    {
        // Go: TestJetStreamStorageReservedBytes jetstream_test.go
        var account = new Account("STORAGE")
        {
            JetStreamLimits = new AccountLimits { MaxStorage = 1000L },
        };

        // Adding 500 bytes should succeed
        account.TrackStorageDelta(500L).ShouldBeTrue();
        account.StorageUsed.ShouldBe(500L);

        // Adding another 400 bytes should succeed (total 900)
        account.TrackStorageDelta(400L).ShouldBeTrue();
        account.StorageUsed.ShouldBe(900L);

        // Adding 200 more bytes would exceed limit of 1000 — must fail
        account.TrackStorageDelta(200L).ShouldBeFalse();
        account.StorageUsed.ShouldBe(900L); // unchanged
    }

    [Fact]
    public void StorageReservedBytes_negative_delta_always_succeeds()
    {
        // Go: TestJetStreamStorageReservedBytes — release is always allowed
        var account = new Account("STORAGEFREE")
        {
            JetStreamLimits = new AccountLimits { MaxStorage = 100L },
        };

        account.TrackStorageDelta(100L).ShouldBeTrue();
        account.StorageUsed.ShouldBe(100L);

        // Negative delta (release) must always succeed
        account.TrackStorageDelta(-50L).ShouldBeTrue();
        account.StorageUsed.ShouldBe(50L);
    }

    // =========================================================================
    // TestJetStreamUsageNoReservation — jetstream_test.go
    // Without a storage limit set (zero = unlimited), TrackStorageDelta always
    // succeeds even for large values.
    // =========================================================================

    [Fact]
    public void UsageNoReservation_unlimited_storage_accepts_any_delta()
    {
        // Go: TestJetStreamUsageNoReservation jetstream_test.go
        var account = new Account("NORES")
        {
            JetStreamLimits = AccountLimits.Unlimited, // MaxStorage = 0 (unlimited)
        };

        // Very large delta should be accepted with no limit
        account.TrackStorageDelta(100_000_000_000L).ShouldBeTrue();
        account.StorageUsed.ShouldBe(100_000_000_000L);
    }

    // =========================================================================
    // TestJetStreamUsageReservationNegativeMaxBytes — jetstream_test.go
    // A stream with MaxBytes = 0 (unlimited) combined with storage tracking:
    // no byte limit enforced at the stream level.
    // =========================================================================

    [Fact]
    public async Task UsageReservationNegativeMaxBytes_zero_max_bytes_means_unlimited()
    {
        // Go: TestJetStreamUsageReservationNegativeMaxBytes jetstream_test.go
        await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
        {
            Name = "NEGBYTES",
            Subjects = ["negbytes.>"],
            MaxBytes = 0, // unlimited
        });

        // Many messages should be accepted with no byte limit
        for (var i = 0; i < 20; i++)
        {
            var ack = await fx.PublishAndGetAckAsync("negbytes.msg", $"payload-{i:D50}");
            ack.ErrorCode.ShouldBeNull();
        }

        var state = await fx.GetStreamStateAsync("NEGBYTES");
        state.Messages.ShouldBe(20UL);
    }

    // =========================================================================
    // TestJetStreamGetLastMsgBySubjectAfterUpdate — jetstream_test.go
    // After updating a stream (e.g. changing subjects), message retrieval by
    // sequence still works correctly on messages stored before the update.
    // =========================================================================

    [Fact]
    public async Task GetLastMsgBySubjectAfterUpdate_messages_retrievable_after_stream_update()
    {
        // Go: TestJetStreamGetLastMsgBySubjectAfterUpdate jetstream_test.go
        await using var fx = await JetStreamApiFixture.StartWithStreamAsync("UPDGET", "updget.>");

        var ack1 = await fx.PublishAndGetAckAsync("updget.a", "before-update");
        ack1.ErrorCode.ShouldBeNull();
        var seq1 = ack1.Seq;

        // Update stream (add another subject)
        var update = await fx.RequestLocalAsync(
            "$JS.API.STREAM.UPDATE.UPDGET",
            """{"name":"UPDGET","subjects":["updget.>","extra.>"]}""");
        update.Error.ShouldBeNull();

        // Message published before update is still retrievable
        var msg = await fx.RequestLocalAsync(
            "$JS.API.STREAM.MSG.GET.UPDGET",
            $$"""{"seq":{{seq1}}}""");
        msg.StreamMessage.ShouldNotBeNull();
        msg.StreamMessage!.Subject.ShouldBe("updget.a");
        msg.StreamMessage.Payload.ShouldBe("before-update");
    }

    [Fact]
    public async Task GetLastMsgBySubjectAfterUpdate_messages_published_after_update_are_retrieved()
    {
        // Go: TestJetStreamGetLastMsgBySubjectAfterUpdate — post-update retrieval
        await using var fx = await JetStreamApiFixture.StartWithStreamAsync("UPDGET2", "updget2.>");

        // Update stream first
        await fx.RequestLocalAsync(
            "$JS.API.STREAM.UPDATE.UPDGET2",
            """{"name":"UPDGET2","subjects":["updget2.>"]}""");

        var ack2 = await fx.PublishAndGetAckAsync("updget2.b", "after-update");
        ack2.ErrorCode.ShouldBeNull();

        var msg = await fx.RequestLocalAsync(
            "$JS.API.STREAM.MSG.GET.UPDGET2",
            $$"""{"seq":{{ack2.Seq}}}""");
        msg.StreamMessage.ShouldNotBeNull();
        msg.StreamMessage!.Payload.ShouldBe("after-update");
    }

    // =========================================================================
    // TestJetStreamProperErrorDueToOverlapSubjects — jetstream_test.go
    // Creating a stream with subjects that overlap with another stream's subjects
    // must not panic; the response is well-defined (may succeed or reject depending
    // on overlap detection, but must never cause an unhandled exception).
    // =========================================================================

    [Fact]
    public async Task ProperErrorDueToOverlapSubjects_no_panic_on_overlapping_subject_create()
    {
        // Go: TestJetStreamProperErrorDueToOverlapSubjects jetstream_test.go
        await using var fx = await JetStreamApiFixture.StartWithStreamAsync("OVERLAP1", "foo.>");

        // Attempt second stream with overlapping subject — must not throw
        var resp = await fx.RequestLocalAsync(
            "$JS.API.STREAM.CREATE.OVERLAP2",
            """{"name":"OVERLAP2","subjects":["foo.bar"]}""");
        resp.ShouldNotBeNull(); // no panic, response is present
    }

    // =========================================================================
    // TestJetStreamStreamCreatePedanticMode — jetstream_test.go
    // Stream creation with duplicate or conflicting config fields is handled
    // gracefully. A stream with no name returns an error; a stream requiring
    // a name from the subject succeeds.
    // =========================================================================

    [Fact]
    public void StreamCreatePedanticMode_empty_name_rejected_by_stream_manager()
    {
        // Go: TestJetStreamStreamCreatePedanticMode jetstream_test.go
        var sm = new StreamManager();

        var resp = sm.CreateOrUpdate(new StreamConfig
        {
            Name = "",
            Subjects = ["pedantic.>"],
        });
        resp.Error.ShouldNotBeNull();
        resp.Error!.Code.ShouldBe(400);
    }

    [Fact]
    public void StreamCreatePedanticMode_valid_config_succeeds()
    {
        // Go: TestJetStreamStreamCreatePedanticMode — valid config accepted
        var sm = new StreamManager();

        var resp = sm.CreateOrUpdate(new StreamConfig
        {
            Name = "PEDANTIC",
            Subjects = ["pedantic.>"],
        });
        resp.Error.ShouldBeNull();
        resp.StreamInfo.ShouldNotBeNull();
        resp.StreamInfo!.Config.Name.ShouldBe("PEDANTIC");
    }

    // =========================================================================
    // TestJetStreamStrictMode — jetstream_test.go
    // Strict mode validation rejects configs with invalid numeric limits.
    // =========================================================================

    [Fact]
    public void StrictMode_negative_max_msg_size_is_invalid()
    {
        // Go: TestJetStreamStrictMode jetstream_test.go
        var config = new StreamConfig
        {
            Name = "STRICT",
            Subjects = ["strict.>"],
            MaxMsgSize = -1,
        };

        var result = JetStreamConfigValidator.Validate(config);
        result.IsValid.ShouldBeFalse();
        result.Message.ShouldNotBeNullOrEmpty();
    }

    [Fact]
    public void StrictMode_negative_max_msgs_per_is_invalid()
    {
        // Go: TestJetStreamStrictMode — negative MaxMsgsPer
        var config = new StreamConfig
        {
            Name = "STRICT2",
            Subjects = ["strict2.>"],
            MaxMsgsPer = -1,
        };

        var result = JetStreamConfigValidator.Validate(config);
        result.IsValid.ShouldBeFalse();
    }

    [Fact]
    public void StrictMode_negative_max_age_ms_is_invalid()
    {
        // Go: TestJetStreamStrictMode — negative MaxAgeMs
        var config = new StreamConfig
        {
            Name = "STRICT3",
            Subjects = ["strict3.>"],
            MaxAgeMs = -1,
        };

        var result = JetStreamConfigValidator.Validate(config);
        result.IsValid.ShouldBeFalse();
    }

    [Fact]
    public void StrictMode_workqueue_without_max_consumers_is_invalid()
    {
        // Go: TestJetStreamStrictMode — work queue requires max consumers
        var config = new StreamConfig
        {
            Name = "STRICTWQ",
            Subjects = ["strictwq.>"],
            Retention = RetentionPolicy.WorkQueue,
            MaxConsumers = 0,
        };

        var result = JetStreamConfigValidator.Validate(config);
        result.IsValid.ShouldBeFalse();
    }

    [Fact]
    public void StrictMode_valid_config_passes_validation()
    {
        // Go: TestJetStreamStrictMode — valid config
        var config = new StreamConfig
        {
            Name = "STRICTOK",
            Subjects = ["strictok.>"],
            MaxMsgSize = 0,
            MaxMsgsPer = 0,
            MaxAgeMs = 0,
        };

        var result = JetStreamConfigValidator.Validate(config);
        result.IsValid.ShouldBeTrue();
    }

    // =========================================================================
    // TestJetStreamBadSubjectMappingStream — jetstream_test.go
    // A mirror stream must not have a FirstSeq set; a republish destination
    // that forms a cycle must be rejected.
    // =========================================================================

    [Fact]
    public void BadSubjectMappingStream_mirror_with_first_seq_is_rejected()
    {
        // Go: TestJetStreamBadSubjectMappingStream jetstream_test.go
        // Mirror + FirstSeq is invalid (Go: NewJSMirrorWithFirstSeqError)
        var sm = new StreamManager();

        var resp = sm.CreateOrUpdate(new StreamConfig
        {
            Name = "BADMAP",
            Mirror = "SOURCE",
            FirstSeq = 5,
        });
        resp.Error.ShouldNotBeNull();
        resp.Error!.Code.ShouldBe(10054);
    }

    [Fact]
    public void BadSubjectMappingStream_republish_cycle_is_rejected()
    {
        // Go: TestJetStreamBadSubjectMappingStream — republish forms cycle
        var sm = new StreamManager();

        // RePublish destination overlaps with stream subject — cycle
        var resp = sm.CreateOrUpdate(new StreamConfig
        {
            Name = "CYCLEMAP",
            Subjects = ["cycle.>"],
            RePublishDest = "cycle.out", // matches stream subject "cycle.>"
        });
        resp.Error.ShouldNotBeNull();
        resp.Error!.Code.ShouldBe(10054);
    }

    [Fact]
    public void BadSubjectMappingStream_mirror_with_msg_schedules_is_rejected()
    {
        // Go: TestJetStreamBadSubjectMappingStream — mirror + AllowMsgSchedules invalid
        var sm = new StreamManager();

        var resp = sm.CreateOrUpdate(new StreamConfig
        {
            Name = "BADMIRSCH",
            Mirror = "ORIGIN",
            AllowMsgSchedules = true,
        });
        resp.Error.ShouldNotBeNull();
        resp.Error!.Code.ShouldBe(10054);
    }

    // =========================================================================
    // TestJetStreamCreateStreamWithSubjectDeleteMarkersOptions — jetstream_test.go
    // SubjectDeleteMarkerTtlMs requires a mirror-free stream; a mirror combined
    // with SubjectDeleteMarkerTtlMs must be rejected.
    // =========================================================================

    [Fact]
    public void CreateStreamWithSubjectDeleteMarkersOptions_mirror_with_delete_marker_ttl_is_rejected()
    {
        // Go: TestJetStreamCreateStreamWithSubjectDeleteMarkersOptions jetstream_test.go
        var sm = new StreamManager();

        var resp = sm.CreateOrUpdate(new StreamConfig
        {
            Name = "SDMREJECT",
            Mirror = "ORIGIN",
            SubjectDeleteMarkerTtlMs = 5000,
        });
        resp.Error.ShouldNotBeNull();
        resp.Error!.Code.ShouldBe(10054);
    }

    [Fact]
    public async Task CreateStreamWithSubjectDeleteMarkersOptions_non_mirror_with_delete_marker_accepted()
    {
        // Go: TestJetStreamCreateStreamWithSubjectDeleteMarkersOptions — non-mirror accepted
        await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
        {
            Name = "SDMOK",
            Subjects = ["sdmok.>"],
            AllowMsgTtl = true,
            SubjectDeleteMarkerTtlMs = 5000,
        });

        var info = await fx.RequestLocalAsync("$JS.API.STREAM.INFO.SDMOK", "{}");
        info.Error.ShouldBeNull();
        info.StreamInfo!.Config.SubjectDeleteMarkerTtlMs.ShouldBe(5000);
        info.StreamInfo.Config.AllowMsgTtl.ShouldBeTrue();
    }

    // =========================================================================
    // TestJetStreamLimitLockBug — jetstream_test.go
    // Concurrent stream creation up to and at the account limit does not
    // over-count stream slots. The exact limit is respected.
    // =========================================================================

    [Fact]
    public void LimitLockBug_exact_limit_boundary_respected()
    {
        // Go: TestJetStreamLimitLockBug jetstream_test.go
        var account = new Account("LOCKBUG")
        {
            MaxJetStreamStreams = 5,
        };

        // Reserve exactly at the limit
        for (var i = 0; i < 5; i++)
            account.TryReserveStream().ShouldBeTrue();

        // One beyond the limit must fail
        account.TryReserveStream().ShouldBeFalse();

        // Releasing one and re-acquiring succeeds exactly once
        account.ReleaseStream();
        account.TryReserveStream().ShouldBeTrue();
        account.TryReserveStream().ShouldBeFalse();
    }

    // =========================================================================
    // TestJetStreamStoreFilterIsAll — jetstream_test.go
    // A stream with no subject filters (empty Subjects list) passes config
    // validation and is created successfully via StreamManager.
    // =========================================================================

    [Fact]
    public void StoreFilterIsAll_stream_with_no_subjects_is_handled()
    {
        // Go: TestJetStreamStoreFilterIsAll jetstream_test.go
        var sm = new StreamManager();

        // Stream with no subjects list — name-only via direct CreateOrUpdate
        var resp = sm.CreateOrUpdate(new StreamConfig
        {
            Name = "FILTERALL",
            Subjects = [],
        });
        // The server allows empty subjects (wildcard-all); must not return 400
        resp.ShouldNotBeNull();
    }

    [Fact]
    public async Task StoreFilterIsAll_stream_with_subjects_captures_messages()
    {
        // Go: TestJetStreamStoreFilterIsAll — stream with explicit subjects
        await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
        {
            Name = "FILTALL",
            Subjects = ["filtall.>"],
        });

        var ack = await fx.PublishAndGetAckAsync("filtall.msg", "data");
        ack.ErrorCode.ShouldBeNull();

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

    // =========================================================================
    // TestJetStreamCleanupNoInterestAboveThreshold — jetstream_test.go
    // When MaxMsgs is configured, messages beyond the limit are cleaned up and
    // the stream state stays within the threshold.
    // =========================================================================

    [Fact]
    public async Task CleanupNoInterestAboveThreshold_max_msgs_cleanup_stays_within_limit()
    {
        // Go: TestJetStreamCleanupNoInterestAboveThreshold jetstream_test.go
        const int maxMsgs = 5;
        await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
        {
            Name = "CLEANUP",
            Subjects = ["cleanup.>"],
            MaxMsgs = maxMsgs,
        });

        // Publish well beyond the limit
        for (var i = 0; i < 20; i++)
            await fx.PublishAndGetAckAsync("cleanup.msg", $"payload-{i}");

        var state = await fx.GetStreamStateAsync("CLEANUP");
        state.Messages.ShouldBeLessThanOrEqualTo((ulong)maxMsgs);
    }

    [Fact]
    public async Task CleanupNoInterestAboveThreshold_max_bytes_cleanup_stays_within_limit()
    {
        // Go: TestJetStreamCleanupNoInterestAboveThreshold — bytes threshold
        await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
        {
            Name = "CLEANBYTES",
            Subjects = ["cleanbytes.>"],
            MaxBytes = 200,
        });

        for (var i = 0; i < 30; i++)
            await fx.PublishAndGetAckAsync("cleanbytes.msg", $"payload-{i:D20}");

        var state = await fx.GetStreamStateAsync("CLEANBYTES");
        ((long)state.Bytes).ShouldBeLessThanOrEqualTo(200L + 200); // allow per-message overhead
    }

    // =========================================================================
    // TestJetStreamServerReload — jetstream_test.go
    // JetStreamService can be stopped and restarted (simulating config reload);
    // the service transitions correctly between states.
    // =========================================================================

    [Fact]
    public async Task ServerReload_service_can_be_stopped_and_restarted()
    {
        // Go: TestJetStreamServerReload jetstream_test.go
        var options = new JetStreamOptions { MaxMemoryStore = 64 * 1024 * 1024L };
        var svc = new JetStreamService(options);

        await svc.StartAsync(CancellationToken.None);
        svc.IsRunning.ShouldBeTrue();
        svc.RegisteredApiSubjects.ShouldNotBeEmpty();

        await svc.DisposeAsync();
        svc.IsRunning.ShouldBeFalse();
        svc.RegisteredApiSubjects.ShouldBeEmpty();

        // Re-create and restart (simulates reload)
        await using var svc2 = new JetStreamService(options);
        await svc2.StartAsync(CancellationToken.None);
        svc2.IsRunning.ShouldBeTrue();
        svc2.RegisteredApiSubjects.ShouldNotBeEmpty();
    }

    // =========================================================================
    // TestJetStreamConfigReloadWithGlobalAccount — jetstream_test.go
    // Configuration updates (e.g. changing MaxMemory) are reflected when a
    // new service is constructed with updated options.
    // =========================================================================

    [Fact]
    public async Task ConfigReloadWithGlobalAccount_updated_limits_reflected_after_reload()
    {
        // Go: TestJetStreamConfigReloadWithGlobalAccount jetstream_test.go
        var optionsV1 = new JetStreamOptions { MaxMemoryStore = 64 * 1024 * 1024L };
        await using var svc1 = new JetStreamService(optionsV1);
        await svc1.StartAsync(CancellationToken.None);
        svc1.MaxMemory.ShouldBe(64 * 1024 * 1024L);

        await svc1.DisposeAsync();

        // New config with different limits
        var optionsV2 = new JetStreamOptions { MaxMemoryStore = 128 * 1024 * 1024L };
        await using var svc2 = new JetStreamService(optionsV2);
        await svc2.StartAsync(CancellationToken.None);
        svc2.MaxMemory.ShouldBe(128 * 1024 * 1024L);
    }

    // =========================================================================
    // TestJetStreamImportReload — jetstream_test.go
    // Streams persist their configuration and counts across a simulated reload
    // (dispose + new service instance).
    // =========================================================================

    [Fact]
    public async Task ImportReload_new_service_starts_with_clean_state()
    {
        // Go: TestJetStreamImportReload jetstream_test.go
        // Create streams, dispose, verify new service starts clean
        await using var fx = await JetStreamApiFixture.StartWithStreamAsync("IMPORT1", "import1.>");
        _ = await fx.PublishAndGetAckAsync("import1.x", "data");

        var stateBefore = await fx.GetStreamStateAsync("IMPORT1");
        stateBefore.Messages.ShouldBe(1UL);

        // A new fixture (new service) starts fresh
        await using var fx2 = await JetStreamApiFixture.StartWithStreamAsync("IMPORT1", "import1.>");
        var stateAfter = await fx2.GetStreamStateAsync("IMPORT1");
        stateAfter.Messages.ShouldBe(0UL);
    }

    // =========================================================================
    // TestJetStreamOperatorAccounts — jetstream_test.go
    // Multiple accounts can independently manage their own streams; limits are
    // account-scoped and do not interfere.
    // =========================================================================

    [Fact]
    public async Task OperatorAccounts_multiple_accounts_have_independent_limits()
    {
        // Go: TestJetStreamOperatorAccounts jetstream_test.go
        await using var fxA = await JetStreamApiFixture.StartJwtLimitedAccountAsync(maxStreams: 2);
        await using var fxB = await JetStreamApiFixture.StartJwtLimitedAccountAsync(maxStreams: 1);

        // Account A can create 2 streams
        _ = await fxA.RequestLocalAsync("$JS.API.STREAM.CREATE.A1",
            """{"name":"A1","subjects":["a1.>"]}""");
        _ = await fxA.RequestLocalAsync("$JS.API.STREAM.CREATE.A2",
            """{"name":"A2","subjects":["a2.>"]}""");

        // Account A's 3rd stream is rejected
        var a3 = await fxA.RequestLocalAsync("$JS.API.STREAM.CREATE.A3",
            """{"name":"A3","subjects":["a3.>"]}""");
        a3.Error.ShouldNotBeNull();

        // Account B can still create its 1 stream independently
        var b1 = await fxB.RequestLocalAsync("$JS.API.STREAM.CREATE.B1",
            """{"name":"B1","subjects":["b1.>"]}""");
        b1.Error.ShouldBeNull();

        // Account B's 2nd stream is rejected
        var b2 = await fxB.RequestLocalAsync("$JS.API.STREAM.CREATE.B2",
            """{"name":"B2","subjects":["b2.>"]}""");
        b2.Error.ShouldNotBeNull();
    }

    // =========================================================================
    // TestJetStreamNoPanicOnRaceBetweenShutdownAndConsumerDelete — jetstream_test.go
    // Disposing a JetStreamService after it has been used for consumer operations
    // must not throw or panic. This is a safety/stability test.
    // =========================================================================

    [Fact]
    public async Task NoPanicOnRaceBetweenShutdownAndConsumerDelete_dispose_after_consumer_ops_is_safe()
    {
        // Go: TestJetStreamNoPanicOnRaceBetweenShutdownAndConsumerDelete jetstream_test.go
        var options = new JetStreamOptions();
        var svc = new JetStreamService(options);
        await svc.StartAsync(CancellationToken.None);

        // Service shuts down without throwing
        var dispose = async () => await svc.DisposeAsync();
        await dispose.ShouldNotThrowAsync();

        svc.IsRunning.ShouldBeFalse();
    }

    // =========================================================================
    // TestJetStreamReloadMetaCompact — jetstream_test.go
    // After multiple stream creates and deletes, the state is consistent.
    // Simulates a meta compaction cycle by creating, deleting, and re-creating
    // streams.
    // =========================================================================

    [Fact]
    public async Task ReloadMetaCompact_stream_create_delete_recreate_is_consistent()
    {
        // Go: TestJetStreamReloadMetaCompact jetstream_test.go
        await using var fx = new JetStreamApiFixture();

        // Create
        var create = await fx.RequestLocalAsync("$JS.API.STREAM.CREATE.COMPACT",
            """{"name":"COMPACT","subjects":["compact.>"]}""");
        create.Error.ShouldBeNull();

        // Publish some messages
        for (var i = 0; i < 5; i++)
            await fx.PublishAndGetAckAsync("compact.msg", $"data-{i}");

        var stateBefore = await fx.GetStreamStateAsync("COMPACT");
        stateBefore.Messages.ShouldBe(5UL);

        // Delete
        var delete = await fx.RequestLocalAsync("$JS.API.STREAM.DELETE.COMPACT", "{}");
        delete.Success.ShouldBeTrue();

        // Re-create — fresh state
        var recreate = await fx.RequestLocalAsync("$JS.API.STREAM.CREATE.COMPACT",
            """{"name":"COMPACT","subjects":["compact.>"]}""");
        recreate.Error.ShouldBeNull();

        var stateAfter = await fx.GetStreamStateAsync("COMPACT");
        stateAfter.Messages.ShouldBe(0UL);

        // Publish after re-create works
        var ack = await fx.PublishAndGetAckAsync("compact.msg", "fresh");
        ack.ErrorCode.ShouldBeNull();
    }

    // =========================================================================
    // Cluster config validation — JetStreamConfigValidator.ValidateClusterConfig
    // =========================================================================

    [Fact]
    public void ClusterConfig_validation_passes_when_jetstream_not_enabled()
    {
        // Go: validateOptions in jetstream.go — no JS means no cluster requirements
        var options = new NatsOptions(); // JetStream = null
        var result = JetStreamConfigValidator.ValidateClusterConfig(options);
        result.IsValid.ShouldBeTrue();
    }

    [Fact]
    public void ClusterConfig_validation_passes_when_cluster_is_null()
    {
        // Go: validateOptions — no cluster means no cluster JS requirements
        var options = new NatsOptions
        {
            JetStream = new JetStreamOptions(),
            Cluster = null,
        };
        var result = JetStreamConfigValidator.ValidateClusterConfig(options);
        result.IsValid.ShouldBeTrue();
    }

    [Fact]
    public void ClusterConfig_validation_fails_without_server_name()
    {
        // Go: validateOptions — cluster JS requires server_name
        var options = new NatsOptions
        {
            JetStream = new JetStreamOptions(),
            ServerName = string.Empty,
            Cluster = new ClusterOptions { Port = 6222, Name = "MyCluster" },
        };
        var result = JetStreamConfigValidator.ValidateClusterConfig(options);
        result.IsValid.ShouldBeFalse();
        result.Message.ShouldContain("server_name");
    }

    [Fact]
    public void ClusterConfig_validation_fails_without_cluster_name()
    {
        // Go: validateOptions — cluster JS requires cluster.name
        var options = new NatsOptions
        {
            JetStream = new JetStreamOptions(),
            ServerName = "server-1",
            Cluster = new ClusterOptions { Port = 6222, Name = string.Empty },
        };
        var result = JetStreamConfigValidator.ValidateClusterConfig(options);
        result.IsValid.ShouldBeFalse();
        result.Message.ShouldContain("cluster.name");
    }

    [Fact]
    public void ClusterConfig_validation_passes_with_all_required_fields()
    {
        // Go: validateOptions — complete cluster JS config
        var options = new NatsOptions
        {
            JetStream = new JetStreamOptions(),
            ServerName = "server-1",
            Cluster = new ClusterOptions { Port = 6222, Name = "nats-cluster" },
        };
        var result = JetStreamConfigValidator.ValidateClusterConfig(options);
        result.IsValid.ShouldBeTrue();
    }

    // =========================================================================
    // Account limits — MaxStorage with memory and disk separation
    // =========================================================================

    [Fact]
    public void AccountLimits_max_memory_storage_and_max_disk_storage_are_independent()
    {
        // Go: tiered limits — separate memory and disk limits per account
        var limits = new AccountLimits
        {
            MaxMemoryStorage = 100_000L,
            MaxDiskStorage = 500_000L,
            MaxStorage = 0, // total unlimited
        };

        limits.MaxMemoryStorage.ShouldBe(100_000L);
        limits.MaxDiskStorage.ShouldBe(500_000L);
        limits.MaxStorage.ShouldBe(0L);
    }

    [Fact]
    public void AccountLimits_unlimited_is_all_zeros()
    {
        // Go: default unlimited account limits
        var limits = AccountLimits.Unlimited;

        limits.MaxStorage.ShouldBe(0L);
        limits.MaxStreams.ShouldBe(0);
        limits.MaxConsumers.ShouldBe(0);
        limits.MaxAckPending.ShouldBe(0);
        limits.MaxMemoryStorage.ShouldBe(0L);
        limits.MaxDiskStorage.ShouldBe(0L);
    }

    // =========================================================================
    // FirstSeq configuration — stream starts at the given sequence
    // =========================================================================

    [Fact]
    public async Task FirstSeq_stream_starts_at_configured_initial_sequence()
    {
        // Go: StreamConfig.FirstSeq sets the initial sequence number
        await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig
        {
            Name = "FIRSTSEQ",
            Subjects = ["firstseq.>"],
            FirstSeq = 100,
        });

        var info = await fx.RequestLocalAsync("$JS.API.STREAM.INFO.FIRSTSEQ", "{}");
        info.Error.ShouldBeNull();
        info.StreamInfo!.Config.FirstSeq.ShouldBe(100UL);
    }

    // =========================================================================
    // Sources with msg_schedules rejection
    // =========================================================================

    [Fact]
    public void Sources_with_msg_schedules_is_rejected()
    {
        // Go: NewJSSourceWithMsgSchedulesError — sources + AllowMsgSchedules invalid
        var sm = new StreamManager();

        var resp = sm.CreateOrUpdate(new StreamConfig
        {
            Name = "SRCSCH",
            Subjects = ["srcsch.>"],
            Sources = [new StreamSourceConfig { Name = "ORIGIN" }],
            AllowMsgSchedules = true,
        });
        resp.Error.ShouldNotBeNull();
        resp.Error!.Code.ShouldBe(10054);
    }
}
