// Copyright 2020-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/jetstream_cluster.go in the NATS server Go source.

using System.Text.Json;
using System.Text.Json.Serialization;

namespace ZB.MOM.NatsNet.Server;

// ============================================================================
// JetStreamCluster
// ============================================================================

/// <summary>
/// Holds cluster-level JetStream state on each server: the meta-controller Raft node,
/// stream/consumer assignment maps, and inflight proposal tracking.
/// Mirrors Go <c>jetStreamCluster</c> struct in server/jetstream_cluster.go lines 44-84.
/// </summary>
internal sealed class JetStreamCluster
{
    /// <summary>The meta-controller Raft node.</summary>
    public IRaftNode? Meta { get; set; }

    /// <summary>
    /// All known stream assignments. Key: account name → stream name → assignment.
    /// </summary>
    public Dictionary<string, Dictionary<string, StreamAssignment>> Streams { get; set; } = new();

    /// <summary>
    /// Inflight stream create/update/delete proposals. Key: account → stream name.
    /// </summary>
    public Dictionary<string, Dictionary<string, InflightStreamInfo>> InflightStreams { get; set; } = new();

    /// <summary>
    /// Inflight consumer create/update/delete proposals. Key: account → stream → consumer.
    /// </summary>
    public Dictionary<string, Dictionary<string, Dictionary<string, InflightConsumerInfo>>> InflightConsumers { get; set; } = new();

    /// <summary>
    /// Peer-remove reply subjects pending quorum. Key: peer ID.
    /// </summary>
    public Dictionary<string, PeerRemoveInfo> PeerRemoveReply { get; set; } = new();

    /// <summary>Signals meta-leader should re-check stream assignments.</summary>
    public bool StreamsCheck { get; set; }

    /// <summary>Reference to the top-level server (object to avoid circular dep).</summary>
    public object? Server { get; set; }

    /// <summary>Internal client for cluster messaging (object to avoid circular dep).</summary>
    public object? Client { get; set; }

    /// <summary>Subscription that receives stream assignment results (object to avoid session dep).</summary>
    public object? StreamResults { get; set; }

    /// <summary>Subscription that receives consumer assignment results (object to avoid session dep).</summary>
    public object? ConsumerResults { get; set; }

    /// <summary>Subscription for meta-leader step-down requests.</summary>
    public object? Stepdown { get; set; }

    /// <summary>Subscription for peer-remove requests.</summary>
    public object? PeerRemove { get; set; }

    /// <summary>Subscription for stream-move requests.</summary>
    public object? PeerStreamMove { get; set; }

    /// <summary>Subscription for stream-move cancellation.</summary>
    public object? PeerStreamCancelMove { get; set; }

    /// <summary>Channel used to pop out monitorCluster before the raft layer.</summary>
    public System.Threading.Channels.Channel<bool>? Qch { get; set; }

    /// <summary>Notifies that monitorCluster has actually stopped.</summary>
    public System.Threading.Channels.Channel<bool>? Stopped { get; set; }

    /// <summary>Last meta-snapshot time (Unix nanoseconds).</summary>
    public long LastMetaSnapTime { get; set; }

    /// <summary>Duration of last meta-snapshot (nanoseconds).</summary>
    public long LastMetaSnapDuration { get; set; }

    internal static UnsupportedStreamAssignment NewUnsupportedStreamAssignment(
        NatsServer server,
        StreamAssignment assignment,
        Exception? error)
    {
        var reason = "stopped";
        if (error != null)
        {
            var msg = error.Message;
            reason = msg.StartsWith("json:", StringComparison.Ordinal)
                ? $"unsupported - config error: {msg["json:".Length..].TrimStart()}"
                : $"stopped - {msg}";
        }
        else if (assignment.Config != null && !JetStreamVersioning.SupportsRequiredApiLevel(assignment.Config.Metadata))
        {
            var required = JetStreamVersioning.GetRequiredApiLevel(assignment.Config.Metadata);
            if (!string.IsNullOrEmpty(required))
                reason = $"unsupported - required API level: {required}, current API level: {JetStreamVersioning.JsApiLevel}";
        }

        var config = assignment.Config ?? new StreamConfig { Name = assignment.Subject ?? string.Empty };
        return new UnsupportedStreamAssignment
        {
            Reason = reason,
            Info = new StreamInfo
            {
                Created = assignment.Created,
                Config = JetStreamVersioning.SetDynamicStreamMetadata(config),
            },
        };
    }

    internal static UnsupportedConsumerAssignment NewUnsupportedConsumerAssignment(
        ConsumerAssignment assignment,
        Exception? error)
    {
        var reason = "stopped";
        if (error != null)
        {
            var msg = error.Message;
            reason = msg.StartsWith("json:", StringComparison.Ordinal)
                ? $"unsupported - config error: {msg["json:".Length..].TrimStart()}"
                : $"stopped - {msg}";
        }
        else if (assignment.Config != null && !JetStreamVersioning.SupportsRequiredApiLevel(assignment.Config.Metadata))
        {
            var required = JetStreamVersioning.GetRequiredApiLevel(assignment.Config.Metadata);
            if (!string.IsNullOrEmpty(required))
                reason = $"unsupported - required API level: {required}, current API level: {JetStreamVersioning.JsApiLevel}";
        }

        var config = assignment.Config ?? new ConsumerConfig { Name = assignment.Name };
        return new UnsupportedConsumerAssignment
        {
            Reason = reason,
            Info = new ConsumerInfo
            {
                Stream = assignment.Stream,
                Name = assignment.Name,
                Created = assignment.Created,
                Config = JetStreamVersioning.SetDynamicConsumerMetadata(config),
                TimeStamp = DateTime.UtcNow,
            },
        };
    }

    internal bool IsLeader()
    {
        if (this == null)
            return true;

        return Meta != null && Meta.Leader();
    }

    internal bool IsStreamCurrent(string account, string stream)
    {
        if (Meta == null)
            return false;

        if (!Streams.TryGetValue(account, out var accountAssignments))
            return false;
        if (!accountAssignments.TryGetValue(stream, out var assignment))
            return false;
        if (assignment.Group == null)
            return false;

        return assignment.Group.Node == null || assignment.Group.Node.Current();
    }

    internal bool IsStreamAssigned(Account account, string stream)
    {
        if (Meta == null)
            return false;

        if (!Streams.TryGetValue(account.Name, out var accountAssignments))
            return false;
        if (!accountAssignments.TryGetValue(stream, out var assignment))
            return false;

        var peers = assignment.Group?.Peers ?? [];
        return peers.Contains(Meta.ID(), StringComparer.Ordinal);
    }

    internal bool IsStreamLeader(string account, string stream)
    {
        if (Meta == null)
            return false;

        if (!Streams.TryGetValue(account, out var accountAssignments))
            return false;
        if (!accountAssignments.TryGetValue(stream, out var assignment))
            return false;

        var group = assignment.Group;
        if (group == null)
            return false;

        var ourId = Meta.ID();
        if (!group.Peers.Contains(ourId, StringComparer.Ordinal))
            return false;

        return group.Peers.Length == 1 || (group.Node != null && group.Node.Leader());
    }

    internal bool IsConsumerLeader(string account, string stream, string consumer)
    {
        if (Meta == null)
            return false;

        if (!Streams.TryGetValue(account, out var accountAssignments))
            return false;
        if (!accountAssignments.TryGetValue(stream, out var assignment))
            return false;
        if (assignment.Consumers == null || !assignment.Consumers.TryGetValue(consumer, out var consumerAssignment))
            return false;

        var group = consumerAssignment.Group;
        if (group == null)
            return false;

        var ourId = Meta.ID();
        if (!group.Peers.Contains(ourId, StringComparer.Ordinal))
            return false;

        return group.Peers.Length == 1 || (group.Node != null && group.Node.Leader());
    }

    internal void TrackInflightStreamProposal(string accountName, StreamAssignment assignment, bool deleted)
    {
        if (!InflightStreams.TryGetValue(accountName, out var streams))
        {
            streams = new Dictionary<string, InflightStreamInfo>(StringComparer.Ordinal);
            InflightStreams[accountName] = streams;
        }

        var streamName = assignment.Config?.Name ?? assignment.Subject ?? string.Empty;
        if (streams.TryGetValue(streamName, out var inflight))
        {
            inflight.Ops++;
            inflight.Deleted = deleted;
            inflight.Assignment = assignment;
            return;
        }

        streams[streamName] = new InflightStreamInfo
        {
            Ops = 1,
            Deleted = deleted,
            Assignment = assignment,
        };
    }

    internal void RemoveInflightStreamProposal(string accountName, string streamName)
    {
        if (!InflightStreams.TryGetValue(accountName, out var streams))
            return;
        if (!streams.TryGetValue(streamName, out var inflight))
            return;

        if (inflight.Ops > 1)
        {
            inflight.Ops--;
            return;
        }

        streams.Remove(streamName);
        if (streams.Count == 0)
            InflightStreams.Remove(accountName);
    }

    internal void TrackInflightConsumerProposal(
        string accountName,
        string streamName,
        ConsumerAssignment assignment,
        bool deleted)
    {
        if (!InflightConsumers.TryGetValue(accountName, out var streams))
        {
            streams = new Dictionary<string, Dictionary<string, InflightConsumerInfo>>(StringComparer.Ordinal);
            InflightConsumers[accountName] = streams;
        }

        if (!streams.TryGetValue(streamName, out var consumers))
        {
            consumers = new Dictionary<string, InflightConsumerInfo>(StringComparer.Ordinal);
            streams[streamName] = consumers;
        }

        if (consumers.TryGetValue(assignment.Name, out var inflight))
        {
            inflight.Ops++;
            inflight.Deleted = deleted;
            inflight.Assignment = assignment;
            return;
        }

        consumers[assignment.Name] = new InflightConsumerInfo
        {
            Ops = 1,
            Deleted = deleted,
            Assignment = assignment,
        };
    }

    internal void RemoveInflightConsumerProposal(string accountName, string streamName, string consumerName)
    {
        if (!InflightConsumers.TryGetValue(accountName, out var streams))
            return;
        if (!streams.TryGetValue(streamName, out var consumers))
            return;
        if (!consumers.TryGetValue(consumerName, out var inflight))
            return;

        if (inflight.Ops > 1)
        {
            inflight.Ops--;
            return;
        }

        consumers.Remove(consumerName);
        if (consumers.Count == 0)
            streams.Remove(streamName);
        if (streams.Count == 0)
            InflightConsumers.Remove(accountName);
    }

    internal static (
        string[] NewPeers,
        string[] OldPeers,
        Dictionary<string, bool> NewPeerSet,
        Dictionary<string, bool> OldPeerSet) GenPeerInfo(string[] peers, int split)
    {
        var safeSplit = Math.Clamp(split, 0, peers.Length);
        var oldPeers = peers.Take(safeSplit).ToArray();
        var newPeers = peers.Skip(safeSplit).ToArray();
        var oldSet = oldPeers.ToDictionary(p => p, _ => true, StringComparer.Ordinal);
        var newSet = newPeers.ToDictionary(p => p, _ => true, StringComparer.Ordinal);
        return (newPeers, oldPeers, newSet, oldSet);
    }

    internal static bool IsControlHdr(byte[]? headers)
    {
        if (headers == null || headers.Length == 0)
            return false;

        var text = System.Text.Encoding.ASCII.GetString(headers);
        return text.Contains(NatsHeaderConstants.JsResponseType, StringComparison.OrdinalIgnoreCase);
    }
}

// ============================================================================
// InflightStreamInfo
// ============================================================================

/// <summary>
/// Tracks inflight stream create/update/delete proposals.
/// Mirrors Go <c>inflightStreamInfo</c> in server/jetstream_cluster.go lines 87-91.
/// </summary>
internal sealed class InflightStreamInfo
{
    /// <summary>Number of inflight operations.</summary>
    public ulong Ops { get; set; }
    /// <summary>Whether the stream has been deleted.</summary>
    public bool Deleted { get; set; }
    public StreamAssignment? Assignment { get; set; }
}

// ============================================================================
// InflightConsumerInfo
// ============================================================================

/// <summary>
/// Tracks inflight consumer create/update/delete proposals.
/// Mirrors Go <c>inflightConsumerInfo</c> in server/jetstream_cluster.go lines 94-98.
/// </summary>
internal sealed class InflightConsumerInfo
{
    /// <summary>Number of inflight operations.</summary>
    public ulong Ops { get; set; }
    /// <summary>Whether the consumer has been deleted.</summary>
    public bool Deleted { get; set; }
    public ConsumerAssignment? Assignment { get; set; }
}

// ============================================================================
// PeerRemoveInfo
// ============================================================================

/// <summary>
/// Holds the reply information for a peer-remove request pending quorum.
/// Mirrors Go <c>peerRemoveInfo</c> in server/jetstream_cluster.go lines 101-106.
/// </summary>
internal sealed class PeerRemoveInfo
{
    /// <summary>Client info from the request (object to avoid session dep).</summary>
    public ClientInfo? ClientInfo { get; set; }
    public string Subject { get; set; } = string.Empty;
    public string Reply   { get; set; } = string.Empty;
    public string Request { get; set; } = string.Empty;
}

// ============================================================================
// EntryOp enum
// ============================================================================

/// <summary>
/// Operation type encoded in a JetStream cluster Raft entry.
/// Mirrors Go <c>entryOp</c> iota in server/jetstream_cluster.go lines 116-150.
/// ONLY ADD TO THE END — inserting values breaks server interoperability.
/// </summary>
internal enum EntryOp : byte
{
    // Meta ops
    AssignStreamOp             = 0,
    AssignConsumerOp           = 1,
    RemoveStreamOp             = 2,
    RemoveConsumerOp           = 3,
    // Stream ops
    StreamMsgOp                = 4,
    PurgeStreamOp              = 5,
    DeleteMsgOp                = 6,
    // Consumer ops
    UpdateDeliveredOp          = 7,
    UpdateAcksOp               = 8,
    // Compressed consumer assignments
    AssignCompressedConsumerOp = 9,
    // Filtered consumer skip
    UpdateSkipOp               = 10,
    // Update stream
    UpdateStreamOp             = 11,
    // Pending pull requests
    AddPendingRequest          = 12,
    RemovePendingRequest       = 13,
    // Compressed streams (RAFT or catchup)
    CompressedStreamMsgOp      = 14,
    // Deleted gaps on catchups for replicas
    DeleteRangeOp              = 15,
    // Batch stream ops
    BatchMsgOp                 = 16,
    BatchCommitMsgOp           = 17,
    // Consumer reset to specific starting sequence
    ResetSeqOp                 = 18,
}

// ============================================================================
// RaftGroup
// ============================================================================

/// <summary>
/// Describes a Raft consensus group that houses streams and consumers.
/// Controlled by the meta-group controller.
/// Mirrors Go <c>raftGroup</c> struct in server/jetstream_cluster.go lines 154-163.
/// </summary>
internal sealed class RaftGroup
{
    [JsonPropertyName("name")]      public string      Name      { get; set; } = string.Empty;
    [JsonPropertyName("peers")]     public string[]    Peers     { get; set; } = [];
    [JsonPropertyName("store")]     public StorageType Storage   { get; set; }
    [JsonPropertyName("cluster")]   public string?     Cluster   { get; set; }
    [JsonPropertyName("preferred")] public string?     Preferred { get; set; }
    [JsonPropertyName("scale_up")]  public bool        ScaleUp   { get; set; }
    /// <summary>Internal Raft node — not serialized.</summary>
    [JsonIgnore]
    public IRaftNode? Node { get; set; }

    internal bool IsMember(string id) =>
        Peers.Contains(id, StringComparer.Ordinal);

    internal void SetPreferred(NatsServer server)
    {
        if (Peers.Length == 0)
            return;
        if (Peers.Length == 1)
        {
            Preferred = Peers[0];
            return;
        }

        var online = new List<string>(Peers.Length);
        foreach (var peer in Peers)
        {
            var info = server.GetNodeInfo(peer);
            if (info is { Offline: false })
                online.Add(peer);
        }

        var candidates = online.Count > 0 ? online : [.. Peers];
        Preferred = candidates[Random.Shared.Next(candidates.Count)];
    }
}

// ============================================================================
// StreamAssignment
// ============================================================================

/// <summary>
/// What the meta controller uses to assign streams to peers.
/// Mirrors Go <c>streamAssignment</c> struct in server/jetstream_cluster.go lines 166-184.
/// </summary>
internal sealed class StreamAssignment
{
    [JsonPropertyName("client")]         public ClientInfo?      Client     { get; set; }
    [JsonPropertyName("created")]        public DateTime         Created    { get; set; }
    [JsonPropertyName("stream")]         public JsonElement      ConfigJson { get; set; }
    [JsonIgnore]                         public StreamConfig?    Config     { get; set; }
    [JsonPropertyName("group")]          public RaftGroup?       Group      { get; set; }
    [JsonPropertyName("sync")]           public string           Sync       { get; set; } = string.Empty;
    [JsonPropertyName("subject")]        public string?          Subject    { get; set; }
    [JsonPropertyName("reply")]          public string?          Reply      { get; set; }
    [JsonPropertyName("restore_state")]  public StreamState?     Restore    { get; set; }

    // Internal (not serialized)
    [JsonIgnore] public Dictionary<string, ConsumerAssignment>? Consumers   { get; set; }
    [JsonIgnore] public bool Responded    { get; set; }
    [JsonIgnore] public bool Recovering   { get; set; }
    [JsonIgnore] public bool Reassigning  { get; set; }
    [JsonIgnore] public bool Resetting    { get; set; }
    [JsonIgnore] public Exception? Error  { get; set; }
    [JsonIgnore] public UnsupportedStreamAssignment? Unsupported { get; set; }

    internal StreamAssignment CopyGroup()
    {
        var clone = new StreamAssignment
        {
            Client = Client,
            Created = Created,
            ConfigJson = ConfigJson,
            Config = Config,
            Group = Group == null
                ? null
                : new RaftGroup
                {
                    Name = Group.Name,
                    Peers = [.. Group.Peers],
                    Storage = Group.Storage,
                    Cluster = Group.Cluster,
                    Preferred = Group.Preferred,
                    ScaleUp = Group.ScaleUp,
                    Node = Group.Node,
                },
            Sync = Sync,
            Subject = Subject,
            Reply = Reply,
            Restore = Restore,
            Consumers = Consumers,
            Responded = Responded,
            Recovering = Recovering,
            Reassigning = Reassigning,
            Resetting = Resetting,
            Error = Error,
            Unsupported = Unsupported,
        };
        return clone;
    }

    internal bool MissingPeers() =>
        Group != null &&
        Config != null &&
        Group.Peers.Length < Math.Max(1, Config.Replicas);

    internal string RecoveryKey() =>
        $"{Client?.ServiceAccount() ?? string.Empty}:{Config?.Name ?? Subject ?? string.Empty}";
}

// ============================================================================
// UnsupportedStreamAssignment
// ============================================================================

/// <summary>
/// Holds state for a stream assignment that this server cannot run,
/// so that it can still respond to cluster info requests.
/// Mirrors Go <c>unsupportedStreamAssignment</c> in server/jetstream_cluster.go lines 186-191.
/// </summary>
internal sealed class UnsupportedStreamAssignment
{
    public string  Reason  { get; set; } = string.Empty;
    public StreamInfo Info  { get; set; } = new();
    /// <summary>Internal system client (object to avoid session dep).</summary>
    public object? SysClient   { get; set; }
    /// <summary>Info subscription (object to avoid session dep).</summary>
    public object? InfoSub     { get; set; }

    internal void SetupInfoSub(NatsServer server, StreamAssignment assignment)
    {
        if (InfoSub != null)
            return;

        SysClient = server.CreateInternalJetStreamClient();
        InfoSub = $"{assignment.Client?.ServiceAccount() ?? string.Empty}:{assignment.Config?.Name ?? string.Empty}";
    }

    internal StreamInfoClusterResponse HandleClusterStreamInfoRequest() =>
        new()
        {
            OfflineReason = Reason,
            StreamInfo = Info,
        };

    internal void CloseInfoSub()
    {
        InfoSub = null;
        SysClient = null;
    }
}

// ============================================================================
// ConsumerAssignment
// ============================================================================

/// <summary>
/// What the meta controller uses to assign consumers to streams.
/// Mirrors Go <c>consumerAssignment</c> struct in server/jetstream_cluster.go lines 250-266.
/// </summary>
internal sealed class ConsumerAssignment
{
    [JsonPropertyName("client")]   public ClientInfo?      Client     { get; set; }
    [JsonPropertyName("created")]  public DateTime         Created    { get; set; }
    [JsonPropertyName("name")]     public string           Name       { get; set; } = string.Empty;
    [JsonPropertyName("stream")]   public string           Stream     { get; set; } = string.Empty;
    [JsonPropertyName("consumer")] public JsonElement      ConfigJson { get; set; }
    [JsonIgnore]                   public ConsumerConfig?  Config     { get; set; }
    [JsonPropertyName("group")]    public RaftGroup?       Group      { get; set; }
    [JsonPropertyName("subject")]  public string?          Subject    { get; set; }
    [JsonPropertyName("reply")]    public string?          Reply      { get; set; }
    [JsonPropertyName("state")]    public ConsumerState?   State      { get; set; }

    // Internal (not serialized)
    [JsonIgnore] public bool Responded   { get; set; }
    [JsonIgnore] public bool Recovering  { get; set; }
    [JsonIgnore] public Exception? Error { get; set; }
    [JsonIgnore] public UnsupportedConsumerAssignment? Unsupported { get; set; }

    internal ConsumerAssignment CopyGroup()
    {
        var clone = new ConsumerAssignment
        {
            Client = Client,
            Created = Created,
            Name = Name,
            Stream = Stream,
            ConfigJson = ConfigJson,
            Config = Config,
            Group = Group == null
                ? null
                : new RaftGroup
                {
                    Name = Group.Name,
                    Peers = [.. Group.Peers],
                    Storage = Group.Storage,
                    Cluster = Group.Cluster,
                    Preferred = Group.Preferred,
                    ScaleUp = Group.ScaleUp,
                    Node = Group.Node,
                },
            Subject = Subject,
            Reply = Reply,
            State = State,
            Responded = Responded,
            Recovering = Recovering,
            Error = Error,
            Unsupported = Unsupported,
        };
        return clone;
    }

    internal string StreamRecoveryKey() =>
        $"{Client?.ServiceAccount() ?? string.Empty}:{Stream}";

    internal string RecoveryKey() =>
        $"{Client?.ServiceAccount() ?? string.Empty}:{Stream}:{Name}";
}

// ============================================================================
// UnsupportedConsumerAssignment
// ============================================================================

/// <summary>
/// Holds state for a consumer assignment that this server cannot run.
/// Mirrors Go <c>unsupportedConsumerAssignment</c> in server/jetstream_cluster.go lines 268-273.
/// </summary>
internal sealed class UnsupportedConsumerAssignment
{
    public string       Reason    { get; set; } = string.Empty;
    public ConsumerInfo Info      { get; set; } = new();
    /// <summary>Internal system client (object to avoid session dep).</summary>
    public object? SysClient      { get; set; }
    /// <summary>Info subscription (object to avoid session dep).</summary>
    public object? InfoSub        { get; set; }

    internal void SetupInfoSub(NatsServer server, ConsumerAssignment assignment)
    {
        if (InfoSub != null)
            return;

        SysClient = server.CreateInternalJetStreamClient();
        InfoSub = $"{assignment.Client?.ServiceAccount() ?? string.Empty}:{assignment.Stream}:{assignment.Name}";
    }

    internal ConsumerInfoClusterResponse HandleClusterConsumerInfoRequest() =>
        new()
        {
            OfflineReason = Reason,
            ConsumerInfo = Info,
        };

    internal void CloseInfoSub()
    {
        InfoSub = null;
        SysClient = null;
    }
}

// ============================================================================
// WriteableConsumerAssignment
// ============================================================================

/// <summary>
/// Serialisable form of a consumer assignment used in meta-snapshots.
/// Mirrors Go <c>writeableConsumerAssignment</c> in server/jetstream_cluster.go lines 332-340.
/// </summary>
internal sealed class WriteableConsumerAssignment
{
    [JsonPropertyName("client")]   public ClientInfo?      Client     { get; set; }
    [JsonPropertyName("created")]  public DateTime         Created    { get; set; }
    [JsonPropertyName("name")]     public string           Name       { get; set; } = string.Empty;
    [JsonPropertyName("stream")]   public string           Stream     { get; set; } = string.Empty;
    [JsonPropertyName("consumer")] public JsonElement      ConfigJson { get; set; }
    [JsonPropertyName("group")]    public RaftGroup?       Group      { get; set; }
    [JsonPropertyName("state")]    public ConsumerState?   State      { get; set; }
}

// ============================================================================
// StreamPurge
// ============================================================================

/// <summary>
/// What the stream leader replicates when purging a stream.
/// Mirrors Go <c>streamPurge</c> struct in server/jetstream_cluster.go lines 343-350.
/// </summary>
internal sealed class StreamPurge
{
    [JsonPropertyName("client")]   public ClientInfo?               Client  { get; set; }
    [JsonPropertyName("stream")]   public string                    Stream  { get; set; } = string.Empty;
    [JsonPropertyName("last_seq")] public ulong                     LastSeq { get; set; }
    [JsonPropertyName("subject")]  public string                    Subject { get; set; } = string.Empty;
    [JsonPropertyName("reply")]    public string                    Reply   { get; set; } = string.Empty;
    [JsonPropertyName("request")]  public JSApiStreamPurgeRequest?  Request { get; set; }
}

// ============================================================================
// StreamMsgDelete
// ============================================================================

/// <summary>
/// What the stream leader replicates when deleting a message.
/// Mirrors Go <c>streamMsgDelete</c> struct in server/jetstream_cluster.go lines 353-360.
/// </summary>
internal sealed class StreamMsgDelete
{
    [JsonPropertyName("client")]    public ClientInfo? Client   { get; set; }
    [JsonPropertyName("stream")]    public string      Stream   { get; set; } = string.Empty;
    [JsonPropertyName("seq")]       public ulong       Seq      { get; set; }
    [JsonPropertyName("no_erase")]  public bool        NoErase  { get; set; }
    [JsonPropertyName("subject")]   public string      Subject  { get; set; } = string.Empty;
    [JsonPropertyName("reply")]     public string      Reply    { get; set; } = string.Empty;
}

// ============================================================================
// RecoveryUpdates
// ============================================================================

/// <summary>
/// Accumulates stream/consumer changes discovered during meta-recovery so they
/// can be applied after the full snapshot has been processed.
/// Mirrors Go <c>recoveryUpdates</c> struct in server/jetstream_cluster.go lines 1327-1333.
/// </summary>
internal sealed class RecoveryUpdates
{
    public Dictionary<string, StreamAssignment>                          RemoveStreams   { get; set; } = new();
    public Dictionary<string, Dictionary<string, ConsumerAssignment>>   RemoveConsumers { get; set; } = new();
    public Dictionary<string, StreamAssignment>                          AddStreams      { get; set; } = new();
    public Dictionary<string, StreamAssignment>                          UpdateStreams   { get; set; } = new();
    public Dictionary<string, Dictionary<string, ConsumerAssignment>>   UpdateConsumers { get; set; } = new();

    internal void RemoveStream(StreamAssignment assignment)
    {
        var key = StreamRecoveryKey(assignment);
        RemoveStreams[key] = assignment;
        AddStreams.Remove(key);
        UpdateStreams.Remove(key);
        UpdateConsumers.Remove(key);
        RemoveConsumers.Remove(key);
    }

    internal void AddStream(StreamAssignment assignment)
    {
        var key = StreamRecoveryKey(assignment);
        AddStreams[key] = assignment;
    }

    internal void UpdateStream(StreamAssignment assignment)
    {
        var key = StreamRecoveryKey(assignment);
        UpdateStreams[key] = assignment;
    }

    internal void RemoveConsumer(ConsumerAssignment assignment)
    {
        var streamKey = ConsumerStreamRecoveryKey(assignment);
        var consumerKey = ConsumerRecoveryKey(assignment);

        if (!RemoveConsumers.TryGetValue(streamKey, out var consumers))
        {
            consumers = new Dictionary<string, ConsumerAssignment>(StringComparer.Ordinal);
            RemoveConsumers[streamKey] = consumers;
        }

        consumers[consumerKey] = assignment;
        if (UpdateConsumers.TryGetValue(streamKey, out var updates))
            updates.Remove(consumerKey);
    }

    internal void AddOrUpdateConsumer(ConsumerAssignment assignment)
    {
        var streamKey = ConsumerStreamRecoveryKey(assignment);
        var consumerKey = ConsumerRecoveryKey(assignment);

        if (!UpdateConsumers.TryGetValue(streamKey, out var consumers))
        {
            consumers = new Dictionary<string, ConsumerAssignment>(StringComparer.Ordinal);
            UpdateConsumers[streamKey] = consumers;
        }

        consumers[consumerKey] = assignment;
    }

    private static string StreamRecoveryKey(StreamAssignment assignment) =>
        $"{assignment.Client?.ServiceAccount() ?? string.Empty}:{assignment.Config?.Name ?? assignment.Subject ?? string.Empty}";

    private static string ConsumerStreamRecoveryKey(ConsumerAssignment assignment) =>
        $"{assignment.Client?.ServiceAccount() ?? string.Empty}:{assignment.Stream}";

    private static string ConsumerRecoveryKey(ConsumerAssignment assignment) =>
        $"{assignment.Stream}:{assignment.Name}";
}

internal sealed class StreamInfoClusterResponse
{
    [JsonPropertyName("offline_reason")]
    public string? OfflineReason { get; set; }

    [JsonPropertyName("stream_info")]
    public StreamInfo? StreamInfo { get; set; }
}

internal sealed class ConsumerInfoClusterResponse
{
    [JsonPropertyName("offline_reason")]
    public string? OfflineReason { get; set; }

    [JsonPropertyName("consumer_info")]
    public ConsumerInfo? ConsumerInfo { get; set; }
}

// ============================================================================
// WriteableStreamAssignment
// ============================================================================

/// <summary>
/// Serialisable form of a stream assignment (with its consumers) used in meta-snapshots.
/// Mirrors Go <c>writeableStreamAssignment</c> in server/jetstream_cluster.go lines 1872-1879.
/// </summary>
internal sealed class WriteableStreamAssignment
{
    [JsonPropertyName("client")]    public ClientInfo?                        Client     { get; set; }
    [JsonPropertyName("created")]   public DateTime                           Created    { get; set; }
    [JsonPropertyName("stream")]    public JsonElement                        ConfigJson { get; set; }
    [JsonPropertyName("group")]     public RaftGroup?                         Group      { get; set; }
    [JsonPropertyName("sync")]      public string                             Sync       { get; set; } = string.Empty;
    public List<WriteableConsumerAssignment>                                  Consumers  { get; set; } = new();
}

// ============================================================================
// ConsumerAssignmentResult
// ============================================================================

/// <summary>
/// Result sent by a member after processing a consumer assignment.
/// Mirrors Go <c>consumerAssignmentResult</c> in server/jetstream_cluster.go lines 5592-5597.
/// </summary>
internal sealed class ConsumerAssignmentResult
{
    [JsonPropertyName("account")]  public string Account  { get; set; } = string.Empty;
    [JsonPropertyName("stream")]   public string Stream   { get; set; } = string.Empty;
    [JsonPropertyName("consumer")] public string Consumer { get; set; } = string.Empty;
    /// <summary>Stub: JSApiConsumerCreateResponse — full type in session 20+.</summary>
    [JsonPropertyName("response")] public object? Response { get; set; }
}

// ============================================================================
// StreamAssignmentResult
// ============================================================================

/// <summary>
/// Result sent by a member after processing a stream assignment.
/// Mirrors Go <c>streamAssignmentResult</c> in server/jetstream_cluster.go lines 6779-6785.
/// </summary>
internal sealed class StreamAssignmentResult
{
    [JsonPropertyName("account")]          public string Account  { get; set; } = string.Empty;
    [JsonPropertyName("stream")]           public string Stream   { get; set; } = string.Empty;
    /// <summary>Stub: JSApiStreamCreateResponse — full type in session 20+.</summary>
    [JsonPropertyName("create_response")]  public object? Response { get; set; }
    /// <summary>Stub: JSApiStreamRestoreResponse — full type in session 20+.</summary>
    [JsonPropertyName("restore_response")] public object? Restore  { get; set; }
    [JsonPropertyName("is_update")]        public bool    Update   { get; set; }
}

// ============================================================================
// SelectPeerError
// ============================================================================

/// <summary>
/// Collects the reasons why no suitable peer could be found for a placement.
/// Mirrors Go <c>selectPeerError</c> struct in server/jetstream_cluster.go lines 7113-7121.
/// </summary>
internal sealed class SelectPeerError : Exception
{
    public bool ExcludeTag  { get; set; }
    public bool Offline     { get; set; }
    public bool NoStorage   { get; set; }
    public bool UniqueTag   { get; set; }
    public bool Misc        { get; set; }
    public bool NoJsClust   { get; set; }
    public HashSet<string>? NoMatchTags  { get; set; }
    public HashSet<string>? ExcludeTags  { get; set; }

    public override string Message => BuildMessage();

    private string BuildMessage()
    {
        var b = new System.Text.StringBuilder("no suitable peers for placement");
        if (Offline)    b.Append(", peer offline");
        if (ExcludeTag) b.Append(", exclude tag set");
        if (NoStorage)  b.Append(", insufficient storage");
        if (UniqueTag)  b.Append(", server tag not unique");
        if (Misc)       b.Append(", miscellaneous issue");
        if (NoJsClust)  b.Append(", jetstream not enabled in cluster");
        if (NoMatchTags is { Count: > 0 })
        {
            b.Append(", tags not matched [");
            b.Append(string.Join(", ", NoMatchTags));
            b.Append(']');
        }
        if (ExcludeTags is { Count: > 0 })
        {
            b.Append(", tags excluded [");
            b.Append(string.Join(", ", ExcludeTags));
            b.Append(']');
        }
        return b.ToString();
    }
}

// ============================================================================
// StreamSnapshot
// ============================================================================

/// <summary>
/// JSON-serialisable snapshot of a stream's state for cluster catchup.
/// Mirrors Go <c>streamSnapshot</c> struct in server/jetstream_cluster.go lines 9454-9461.
/// </summary>
internal sealed class StreamSnapshot
{
    [JsonPropertyName("messages")]  public ulong    Msgs     { get; set; }
    [JsonPropertyName("bytes")]     public ulong    Bytes    { get; set; }
    [JsonPropertyName("first_seq")] public ulong    FirstSeq { get; set; }
    [JsonPropertyName("last_seq")]  public ulong    LastSeq  { get; set; }
    [JsonPropertyName("clfs")]      public ulong    Failed   { get; set; }
    [JsonPropertyName("deleted")]   public ulong[]? Deleted  { get; set; }
}

// ============================================================================
// StreamSyncRequest
// ============================================================================

/// <summary>
/// Request sent by a lagging follower to trigger stream catch-up from the leader.
/// Mirrors Go <c>streamSyncRequest</c> struct in server/jetstream_cluster.go lines 9707-9713.
/// </summary>
internal sealed class StreamSyncRequest
{
    [JsonPropertyName("peer")]           public string? Peer           { get; set; }
    [JsonPropertyName("first_seq")]      public ulong   FirstSeq       { get; set; }
    [JsonPropertyName("last_seq")]       public ulong   LastSeq        { get; set; }
    [JsonPropertyName("delete_ranges")]  public bool    DeleteRangesOk { get; set; }
    [JsonPropertyName("min_applied")]    public ulong   MinApplied     { get; set; }
}

// ============================================================================
// Stub API request/response types (referenced by cluster types; full impl later)
// ============================================================================

/// <summary>Stub: full definition in session 21 (jetstream_api.go).</summary>
internal sealed class JSApiStreamPurgeRequest
{
    [JsonPropertyName("filter")]   public string? Filter  { get; set; }
    [JsonPropertyName("seq")]      public ulong?  Seq     { get; set; }
    [JsonPropertyName("keep")]     public ulong?  Keep    { get; set; }
}
