// Go parity: golang/nats-server/server/jetstream_cluster.go
// Covers: JetStreamMetaGroup RAFT proposal workflow — stream create/delete,
// consumer create/delete, leader validation, duplicate rejection,
// ApplyEntry dispatch, inflight tracking, leader change clearing inflight,
// GetState snapshot with consumer counts.
using NATS.Server.JetStream.Cluster;
using NATS.Server.JetStream.Models;

namespace NATS.Server.JetStream.Tests.JetStream.Cluster;

/// <summary>
/// Tests for JetStreamMetaGroup RAFT proposal workflow.
/// Go reference: jetstream_cluster.go:500-2000 (processStreamAssignment,
/// processConsumerAssignment, meta group leader logic).
/// </summary>
public class MetaGroupProposalTests
{
    // ---------------------------------------------------------------
    // Stream create proposal
    // Go reference: jetstream_cluster.go processStreamAssignment
    // ---------------------------------------------------------------

    [Fact]
    public async Task Stream_create_proposal_adds_stream_assignment()
    {
        var meta = new JetStreamMetaGroup(3);
        var group = new RaftGroup { Name = "test-group", Peers = ["p1", "p2", "p3"] };

        await meta.ProposeCreateStreamValidatedAsync(new StreamConfig { Name = "ORDERS" }, group, default);

        var assignment = meta.GetStreamAssignment("ORDERS");
        assignment.ShouldNotBeNull();
        assignment.StreamName.ShouldBe("ORDERS");
        assignment.Group.ShouldBeSameAs(group);
    }

    [Fact]
    public async Task Stream_create_proposal_increments_stream_count()
    {
        var meta = new JetStreamMetaGroup(3);

        await meta.ProposeCreateStreamValidatedAsync(new StreamConfig { Name = "S1" }, null, default);
        await meta.ProposeCreateStreamValidatedAsync(new StreamConfig { Name = "S2" }, null, default);

        meta.StreamCount.ShouldBe(2);
    }

    [Fact]
    public async Task Stream_create_proposal_appears_in_state()
    {
        var meta = new JetStreamMetaGroup(3);

        await meta.ProposeCreateStreamValidatedAsync(new StreamConfig { Name = "EVENTS" }, null, default);

        var state = meta.GetState();
        state.Streams.ShouldContain("EVENTS");
        state.AssignmentCount.ShouldBe(1);
    }

    // ---------------------------------------------------------------
    // Stream delete proposal
    // Go reference: jetstream_cluster.go processStreamDelete
    // ---------------------------------------------------------------

    [Fact]
    public async Task Stream_delete_proposal_removes_stream()
    {
        var meta = new JetStreamMetaGroup(3);
        await meta.ProposeCreateStreamValidatedAsync(new StreamConfig { Name = "DOOMED" }, null, default);

        await meta.ProposeDeleteStreamValidatedAsync("DOOMED", default);

        meta.GetStreamAssignment("DOOMED").ShouldBeNull();
        meta.StreamCount.ShouldBe(0);
        meta.GetState().Streams.ShouldNotContain("DOOMED");
    }

    [Fact]
    public async Task Stream_delete_with_consumers_decrements_consumer_count()
    {
        var meta = new JetStreamMetaGroup(3);
        var sg = new RaftGroup { Name = "sg", Peers = ["p1"] };
        var cg = new RaftGroup { Name = "cg", Peers = ["p1"] };

        await meta.ProposeCreateStreamValidatedAsync(new StreamConfig { Name = "S" }, sg, default);
        await meta.ProposeCreateConsumerValidatedAsync("S", "C1", cg, default);
        await meta.ProposeCreateConsumerValidatedAsync("S", "C2", cg, default);
        meta.ConsumerCount.ShouldBe(2);

        await meta.ProposeDeleteStreamValidatedAsync("S", default);
        meta.ConsumerCount.ShouldBe(0);
    }

    // ---------------------------------------------------------------
    // Consumer create/delete proposal
    // Go reference: jetstream_cluster.go processConsumerAssignment/Delete
    // ---------------------------------------------------------------

    [Fact]
    public async Task Consumer_create_proposal_adds_consumer_to_stream()
    {
        var meta = new JetStreamMetaGroup(3);
        var sg = new RaftGroup { Name = "sg", Peers = ["p1", "p2", "p3"] };
        var cg = new RaftGroup { Name = "cg", Peers = ["p1"] };

        await meta.ProposeCreateStreamValidatedAsync(new StreamConfig { Name = "ORDERS" }, sg, default);
        await meta.ProposeCreateConsumerValidatedAsync("ORDERS", "PROCESSOR", cg, default);

        var ca = meta.GetConsumerAssignment("ORDERS", "PROCESSOR");
        ca.ShouldNotBeNull();
        ca.ConsumerName.ShouldBe("PROCESSOR");
        ca.StreamName.ShouldBe("ORDERS");
        meta.ConsumerCount.ShouldBe(1);
    }

    [Fact]
    public async Task Consumer_delete_proposal_removes_consumer()
    {
        var meta = new JetStreamMetaGroup(3);
        var sg = new RaftGroup { Name = "sg", Peers = ["p1"] };
        var cg = new RaftGroup { Name = "cg", Peers = ["p1"] };

        await meta.ProposeCreateStreamValidatedAsync(new StreamConfig { Name = "S" }, sg, default);
        await meta.ProposeCreateConsumerValidatedAsync("S", "C1", cg, default);
        meta.ConsumerCount.ShouldBe(1);

        await meta.ProposeDeleteConsumerValidatedAsync("S", "C1", default);
        meta.GetConsumerAssignment("S", "C1").ShouldBeNull();
        meta.ConsumerCount.ShouldBe(0);
    }

    [Fact]
    public async Task Multiple_consumers_tracked_independently()
    {
        var meta = new JetStreamMetaGroup(3);
        var sg = new RaftGroup { Name = "sg", Peers = ["p1"] };
        var cg = new RaftGroup { Name = "cg", Peers = ["p1"] };

        await meta.ProposeCreateStreamValidatedAsync(new StreamConfig { Name = "MULTI" }, sg, default);
        await meta.ProposeCreateConsumerValidatedAsync("MULTI", "C1", cg, default);
        await meta.ProposeCreateConsumerValidatedAsync("MULTI", "C2", cg, default);
        await meta.ProposeCreateConsumerValidatedAsync("MULTI", "C3", cg, default);

        meta.ConsumerCount.ShouldBe(3);
        meta.GetStreamAssignment("MULTI")!.Consumers.Count.ShouldBe(3);

        await meta.ProposeDeleteConsumerValidatedAsync("MULTI", "C2", default);
        meta.ConsumerCount.ShouldBe(2);
        meta.GetConsumerAssignment("MULTI", "C2").ShouldBeNull();
        meta.GetConsumerAssignment("MULTI", "C1").ShouldNotBeNull();
        meta.GetConsumerAssignment("MULTI", "C3").ShouldNotBeNull();
    }

    // ---------------------------------------------------------------
    // Not-leader rejects proposals
    // Go reference: jetstream_api.go:200-300 — leader check
    // ---------------------------------------------------------------

    [Fact]
    public void Not_leader_rejects_stream_create()
    {
        // selfIndex=2 but leaderIndex starts at 1, so IsLeader() is false
        var meta = new JetStreamMetaGroup(3, selfIndex: 2);

        var ex = Should.Throw<InvalidOperationException>(
            () => meta.ProposeCreateStreamValidatedAsync(new StreamConfig { Name = "FAIL" }, null, default));

        ex.Message.ShouldContain("Not the meta-group leader");
    }

    [Fact]
    public void Not_leader_rejects_stream_delete()
    {
        var meta = new JetStreamMetaGroup(3, selfIndex: 2);

        var ex = Should.Throw<InvalidOperationException>(
            () => meta.ProposeDeleteStreamValidatedAsync("S", default));

        ex.Message.ShouldContain("Not the meta-group leader");
    }

    [Fact]
    public void Not_leader_rejects_consumer_create()
    {
        var meta = new JetStreamMetaGroup(3, selfIndex: 2);
        var cg = new RaftGroup { Name = "cg", Peers = ["p1"] };

        var ex = Should.Throw<InvalidOperationException>(
            () => meta.ProposeCreateConsumerValidatedAsync("S", "C1", cg, default));

        ex.Message.ShouldContain("Not the meta-group leader");
    }

    [Fact]
    public void Not_leader_rejects_consumer_delete()
    {
        var meta = new JetStreamMetaGroup(3, selfIndex: 2);

        var ex = Should.Throw<InvalidOperationException>(
            () => meta.ProposeDeleteConsumerValidatedAsync("S", "C1", default));

        ex.Message.ShouldContain("Not the meta-group leader");
    }

    // ---------------------------------------------------------------
    // Duplicate stream name rejected (validated path)
    // Go reference: jetstream_cluster.go duplicate stream check
    // ---------------------------------------------------------------

    [Fact]
    public async Task Duplicate_stream_name_rejected_by_validated_proposal()
    {
        var meta = new JetStreamMetaGroup(3);

        await meta.ProposeCreateStreamValidatedAsync(new StreamConfig { Name = "DUP" }, null, default);

        var ex = Should.Throw<InvalidOperationException>(
            () => meta.ProposeCreateStreamValidatedAsync(new StreamConfig { Name = "DUP" }, null, default));

        ex.Message.ShouldContain("already exists");
    }

    // ---------------------------------------------------------------
    // Consumer on non-existent stream rejected (validated path)
    // Go reference: jetstream_cluster.go stream existence check
    // ---------------------------------------------------------------

    [Fact]
    public void Consumer_on_nonexistent_stream_rejected_by_validated_proposal()
    {
        var meta = new JetStreamMetaGroup(3);
        var cg = new RaftGroup { Name = "cg", Peers = ["p1"] };

        var ex = Should.Throw<InvalidOperationException>(
            () => meta.ProposeCreateConsumerValidatedAsync("MISSING", "C1", cg, default));

        ex.Message.ShouldContain("not found");
    }

    // ---------------------------------------------------------------
    // ApplyEntry dispatch
    // Go reference: jetstream_cluster.go RAFT apply for meta group
    // ---------------------------------------------------------------

    [Fact]
    public void ApplyEntry_stream_create_adds_assignment()
    {
        var meta = new JetStreamMetaGroup(3);
        var group = new RaftGroup { Name = "APPLIED", Peers = ["p1"] };

        meta.ApplyEntry(MetaEntryType.StreamCreate, "APPLIED", group: group);

        meta.GetStreamAssignment("APPLIED").ShouldNotBeNull();
        meta.StreamCount.ShouldBe(1);
    }

    [Fact]
    public void ApplyEntry_stream_delete_removes_assignment()
    {
        var meta = new JetStreamMetaGroup(3);
        meta.ApplyEntry(MetaEntryType.StreamCreate, "TEMP");

        meta.ApplyEntry(MetaEntryType.StreamDelete, "TEMP");

        meta.GetStreamAssignment("TEMP").ShouldBeNull();
    }

    [Fact]
    public void ApplyEntry_consumer_create_adds_consumer()
    {
        var meta = new JetStreamMetaGroup(3);
        meta.ApplyEntry(MetaEntryType.StreamCreate, "S");

        meta.ApplyEntry(MetaEntryType.ConsumerCreate, "C1", streamName: "S");

        meta.GetConsumerAssignment("S", "C1").ShouldNotBeNull();
        meta.ConsumerCount.ShouldBe(1);
    }

    [Fact]
    public void ApplyEntry_consumer_delete_removes_consumer()
    {
        var meta = new JetStreamMetaGroup(3);
        meta.ApplyEntry(MetaEntryType.StreamCreate, "S");
        meta.ApplyEntry(MetaEntryType.ConsumerCreate, "C1", streamName: "S");

        meta.ApplyEntry(MetaEntryType.ConsumerDelete, "C1", streamName: "S");

        meta.GetConsumerAssignment("S", "C1").ShouldBeNull();
        meta.ConsumerCount.ShouldBe(0);
    }

    [Fact]
    public void ApplyEntry_consumer_without_stream_name_throws()
    {
        var meta = new JetStreamMetaGroup(3);

        Should.Throw<ArgumentNullException>(
            () => meta.ApplyEntry(MetaEntryType.ConsumerCreate, "C1"));
    }

    // ---------------------------------------------------------------
    // Inflight tracking
    // Go reference: jetstream_cluster.go inflight tracking
    // ---------------------------------------------------------------

    [Fact]
    public async Task Inflight_cleared_after_stream_create()
    {
        var meta = new JetStreamMetaGroup(3);

        await meta.ProposeCreateStreamAsync(new StreamConfig { Name = "INF" }, default);

        // Inflight should be cleared after proposal completes
        meta.InflightStreamCount.ShouldBe(0);
    }

    [Fact]
    public async Task Inflight_cleared_after_consumer_create()
    {
        var meta = new JetStreamMetaGroup(3);
        await meta.ProposeCreateStreamAsync(new StreamConfig { Name = "S" }, default);

        var cg = new RaftGroup { Name = "cg", Peers = ["p1"] };
        await meta.ProposeCreateConsumerAsync("S", "C1", cg, default);

        meta.InflightConsumerCount.ShouldBe(0);
    }

    // ---------------------------------------------------------------
    // Leader change clears inflight
    // Go reference: jetstream_cluster.go leader stepdown
    // ---------------------------------------------------------------

    [Fact]
    public void Leader_change_clears_inflight()
    {
        var meta = new JetStreamMetaGroup(3);

        // Manually inspect that step down clears (inflight is always 0 after
        // synchronous proposal, but the StepDown path is the important semantic).
        meta.StepDown();

        meta.InflightStreamCount.ShouldBe(0);
        meta.InflightConsumerCount.ShouldBe(0);
    }

    [Fact]
    public void StepDown_increments_leadership_version()
    {
        var meta = new JetStreamMetaGroup(3);
        var versionBefore = meta.GetState().LeadershipVersion;

        meta.StepDown();

        meta.GetState().LeadershipVersion.ShouldBeGreaterThan(versionBefore);
    }

    // ---------------------------------------------------------------
    // GetState returns correct snapshot
    // Go reference: jetstream_cluster.go meta group state
    // ---------------------------------------------------------------

    [Fact]
    public async Task GetState_returns_correct_snapshot()
    {
        var meta = new JetStreamMetaGroup(5);

        await meta.ProposeCreateStreamAsync(new StreamConfig { Name = "ALPHA" }, default);
        await meta.ProposeCreateStreamAsync(new StreamConfig { Name = "BETA" }, default);
        var cg = new RaftGroup { Name = "cg", Peers = ["p1"] };
        await meta.ProposeCreateConsumerAsync("ALPHA", "C1", cg, default);
        await meta.ProposeCreateConsumerAsync("ALPHA", "C2", cg, default);
        await meta.ProposeCreateConsumerAsync("BETA", "C1", cg, default);

        var state = meta.GetState();

        state.ClusterSize.ShouldBe(5);
        state.Streams.Count.ShouldBe(2);
        state.AssignmentCount.ShouldBe(2);
        state.ConsumerCount.ShouldBe(3);
        state.LeaderId.ShouldBe("meta-1");
    }

    [Fact]
    public async Task GetState_streams_are_sorted()
    {
        var meta = new JetStreamMetaGroup(3);

        await meta.ProposeCreateStreamAsync(new StreamConfig { Name = "ZULU" }, default);
        await meta.ProposeCreateStreamAsync(new StreamConfig { Name = "ALPHA" }, default);
        await meta.ProposeCreateStreamAsync(new StreamConfig { Name = "MIKE" }, default);

        var state = meta.GetState();
        state.Streams[0].ShouldBe("ALPHA");
        state.Streams[1].ShouldBe("MIKE");
        state.Streams[2].ShouldBe("ZULU");
    }

    // ---------------------------------------------------------------
    // GetAllAssignments
    // ---------------------------------------------------------------

    [Fact]
    public async Task GetAllAssignments_returns_all_streams()
    {
        var meta = new JetStreamMetaGroup(3);

        await meta.ProposeCreateStreamAsync(new StreamConfig { Name = "A" }, default);
        await meta.ProposeCreateStreamAsync(new StreamConfig { Name = "B" }, default);

        var all = meta.GetAllAssignments();
        all.Count.ShouldBe(2);
    }

    // ---------------------------------------------------------------
    // GetConsumerAssignment
    // ---------------------------------------------------------------

    [Fact]
    public void GetConsumerAssignment_returns_null_for_nonexistent_stream()
    {
        var meta = new JetStreamMetaGroup(3);

        meta.GetConsumerAssignment("MISSING", "C1").ShouldBeNull();
    }

    [Fact]
    public async Task GetConsumerAssignment_returns_null_for_nonexistent_consumer()
    {
        var meta = new JetStreamMetaGroup(3);
        await meta.ProposeCreateStreamAsync(new StreamConfig { Name = "S" }, default);

        meta.GetConsumerAssignment("S", "MISSING").ShouldBeNull();
    }

    // ---------------------------------------------------------------
    // Idempotent backward-compatible paths
    // ---------------------------------------------------------------

    [Fact]
    public async Task Duplicate_stream_create_is_idempotent_via_unvalidated_path()
    {
        var meta = new JetStreamMetaGroup(3);

        await meta.ProposeCreateStreamAsync(new StreamConfig { Name = "DUP" }, default);
        await meta.ProposeCreateStreamAsync(new StreamConfig { Name = "DUP" }, default);

        meta.StreamCount.ShouldBe(1);
    }

    [Fact]
    public async Task Consumer_on_nonexistent_stream_is_silent_via_unvalidated_path()
    {
        var meta = new JetStreamMetaGroup(3);
        var cg = new RaftGroup { Name = "cg", Peers = ["p1"] };

        // Should not throw
        await meta.ProposeCreateConsumerAsync("MISSING", "C1", cg, default);

        meta.GetStreamAssignment("MISSING").ShouldBeNull();
    }
}
