/*
 * Copyright 2017 Google
 *
 * 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.
 */

#import "FIRMessagingConnection.h"

#import "Protos/GtalkCore.pbobjc.h"
#import "Protos/GtalkExtensions.pbobjc.h"

#import "FIRMessaging.h"
#import "FIRMessagingDataMessageManager.h"
#import "FIRMessagingDefines.h"
#import "FIRMessagingLogger.h"
#import "FIRMessagingRmqManager.h"
#import "FIRMessagingSecureSocket.h"
#import "FIRMessagingUtilities.h"
#import "FIRMessagingVersionUtilities.h"
#import "FIRMessaging_Private.h"

static NSInteger const kIqSelectiveAck = 12;
static NSInteger const kIqStreamAck = 13;
static int const kInvalidStreamId = -1;
// Threshold for number of messages removed that we will ack, for short lived connections
static int const kMessageRemoveAckThresholdCount = 5;

static NSTimeInterval const kHeartbeatInterval = 30.0;
static NSTimeInterval const kConnectionTimeout = 20.0;
static int32_t const kAckingInterval = 10;

static NSString *const kUnackedS2dIdKey = @"FIRMessagingUnackedS2dIdKey";
static NSString *const kAckedS2dIdMapKey = @"FIRMessagingAckedS2dIdMapKey";

static NSString *const kRemoteFromAddress = @"from";

@interface FIRMessagingD2SInfo : NSObject

@property(nonatomic, readwrite, assign) int streamId;
@property(nonatomic, readwrite, strong) NSString *d2sID;
- (instancetype)initWithStreamId:(int)streamId d2sId:(NSString *)d2sID;

@end

@implementation FIRMessagingD2SInfo

- (instancetype)initWithStreamId:(int)streamId d2sId:(NSString *)d2sID {
  self = [super init];
  if (self) {
    _streamId = streamId;
    _d2sID = [d2sID copy];
  }
  return self;
}

- (BOOL)isEqual:(id)object {
  if ([object isKindOfClass:[self class]]) {
    FIRMessagingD2SInfo *other = (FIRMessagingD2SInfo *)object;
    return self.streamId == other.streamId && [self.d2sID isEqualToString:other.d2sID];
  }
  return NO;
}

- (NSUInteger)hash {
  return [self.d2sID hash];
}

@end

@interface FIRMessagingConnection ()<FIRMessagingSecureSocketDelegate>

@property(nonatomic, readwrite, weak) FIRMessagingRmqManager *rmq2Manager;
@property(nonatomic, readwrite, weak) FIRMessagingDataMessageManager *dataMessageManager;

@property(nonatomic, readwrite, assign) FIRMessagingConnectionState state;
@property(nonatomic, readwrite, copy) NSString *host;
@property(nonatomic, readwrite, assign) NSUInteger port;

@property(nonatomic, readwrite, strong) NSString *authId;
@property(nonatomic, readwrite, strong) NSString *token;

@property(nonatomic, readwrite, strong) FIRMessagingSecureSocket *socket;

@property(nonatomic, readwrite, assign) int64_t lastLoginServerTimestamp;
@property(nonatomic, readwrite, assign) int lastStreamIdAcked;
@property(nonatomic, readwrite, assign) int inStreamId;
@property(nonatomic, readwrite, assign) int outStreamId;

@property(nonatomic, readwrite, strong) NSMutableArray *unackedS2dIds;
@property(nonatomic, readwrite, strong) NSMutableDictionary *ackedS2dMap;
@property(nonatomic, readwrite, strong) NSMutableArray *d2sInfos;
// ttl=0 messages that need to be sent as soon as we establish a connection
@property(nonatomic, readwrite, strong) NSMutableArray *sendOnConnectMessages;

@property(nonatomic, readwrite, strong) NSRunLoop *runLoop;

@end


@implementation FIRMessagingConnection;

- (instancetype)initWithAuthID:(NSString *)authId
                         token:(NSString *)token
                          host:(NSString *)host
                          port:(NSUInteger)port
                       runLoop:(NSRunLoop *)runLoop
                   rmq2Manager:(FIRMessagingRmqManager *)rmq2Manager
                    fcmManager:(FIRMessagingDataMessageManager *)dataMessageManager {
  self = [super init];
  if (self) {
    _authId = [authId copy];
    _token = [token copy];
    _host = [host copy];
    _port = port;
    _runLoop = runLoop;
    _rmq2Manager = rmq2Manager;
    _dataMessageManager = dataMessageManager;

    _d2sInfos = [NSMutableArray array];

    _unackedS2dIds = [NSMutableArray arrayWithArray:[_rmq2Manager unackedS2dRmqIds]];
    _ackedS2dMap = [NSMutableDictionary dictionary];
    _sendOnConnectMessages = [NSMutableArray array];
  }
  return self;
}

- (NSString *)description {
  return [NSString stringWithFormat:@"host: %@, port: %lu, stream id in: %d, stream id out: %d",
          self.host,
          _FIRMessaging_UL(self.port),
          self.inStreamId,
          self.outStreamId];
}

- (void)signIn {
  _FIRMessagingDevAssert(self.state == kFIRMessagingConnectionNotConnected, @"Invalid connection state.");
  if (self.state != kFIRMessagingConnectionNotConnected) {
    return;
  }

  // break it up for testing
  [self setupConnectionSocket];
  [self connectToSocket:self.socket];
}

- (void)setupConnectionSocket {
  self.socket = [[FIRMessagingSecureSocket alloc] init];
  self.socket.delegate = self;
}

- (void)connectToSocket:(FIRMessagingSecureSocket *)socket {
  self.state = kFIRMessagingConnectionConnecting;
  FIRMessagingLoggerDebug(kFIRMessagingMessageCodeConnection000,
                          @"Start connecting to FIRMessaging service.");
  [socket connectToHost:self.host port:self.port onRunLoop:self.runLoop];
}

- (void)signOut {
  // Clear the list of messages to be sent on connect. This will only
  // have messages in it if an error happened before receiving the LoginResponse.
  [self.sendOnConnectMessages removeAllObjects];

  if (self.state == kFIRMessagingConnectionSignedIn) {
    [self sendClose];
  }
  if (self.state != kFIRMessagingConnectionNotConnected) {
    [self disconnect];
  }
}

- (void)teardown {
  if (self.state != kFIRMessagingConnectionNotConnected) {
    [self disconnect];
  }
}

#pragma mark - FIRMessagingSecureSocketDelegate

- (void)secureSocketDidConnect:(FIRMessagingSecureSocket *)socket {
  self.state = kFIRMessagingConnectionConnected;
  self.lastStreamIdAcked = 0;
  self.inStreamId = 0;
  self.outStreamId = 0;

  FIRMessagingLoggerDebug(kFIRMessagingMessageCodeConnection001,
                          @"Connected to FIRMessaging service.");
  [self resetUnconfirmedAcks];
  [self sendLoginRequest:self.authId token:self.token];
}

- (void)didDisconnectWithSecureSocket:(FIRMessagingSecureSocket *)socket {
  _FIRMessagingDevAssert(self.socket == socket, @"Invalid socket");
  _FIRMessagingDevAssert(self.socket.state == kFIRMessagingSecureSocketClosed, @"Socket already closed");

  FIRMessagingLoggerDebug(kFIRMessagingMessageCodeConnection002,
                          @"Secure socket disconnected from FIRMessaging service.");
  [self disconnect];
  [self.delegate connection:self didCloseForReason:kFIRMessagingConnectionCloseReasonSocketDisconnected];
}

- (void)secureSocket:(FIRMessagingSecureSocket *)socket
      didReceiveData:(NSData *)data
             withTag:(int8_t)tag {
  if (tag < 0) {
    // Invalid proto tag
    return;
  }

  Class klassForTag = FIRMessagingGetClassForTag((FIRMessagingProtoTag)tag);
  if ([klassForTag isSubclassOfClass:[NSNull class]]) {
    FIRMessagingLoggerError(kFIRMessagingMessageCodeConnection003, @"Invalid tag %d for proto",
                            tag);
    return;
  }

  GPBMessage *proto = [klassForTag parseFromData:data error:NULL];
  if (tag == kFIRMessagingProtoTagLoginResponse && self.state != kFIRMessagingConnectionConnected) {
    FIRMessagingLoggerDebug(
        kFIRMessagingMessageCodeConnection004,
        @"Should not receive generated message when the connection is not connected.");
    return;
  } else if (tag != kFIRMessagingProtoTagLoginResponse && self.state != kFIRMessagingConnectionSignedIn) {
    FIRMessagingLoggerDebug(
        kFIRMessagingMessageCodeConnection005,
        @"Should not receive generated message when the connection is not signed in.");
    return;
  }

  // If traffic is received after a heartbeat it is safe to assume the connection is healthy.
  [self cancelConnectionTimeoutTask];
  [self performSelector:@selector(sendHeartbeatPing)
             withObject:nil
             afterDelay:kHeartbeatInterval];

  [self willProcessProto:proto];
  switch (tag) {
    case kFIRMessagingProtoTagLoginResponse:
      [self didReceiveLoginResponse:(GtalkLoginResponse *)proto];
      break;
    case kFIRMessagingProtoTagDataMessageStanza:
      [self didReceiveDataMessageStanza:(GtalkDataMessageStanza *)proto];
      break;
    case kFIRMessagingProtoTagHeartbeatPing:
      [self didReceiveHeartbeatPing:(GtalkHeartbeatPing *)proto];
      break;
    case kFIRMessagingProtoTagHeartbeatAck:
      [self didReceiveHeartbeatAck:(GtalkHeartbeatAck *)proto];
      break;
    case kFIRMessagingProtoTagClose:
      [self didReceiveClose:(GtalkClose *)proto];
      break;
    case kFIRMessagingProtoTagIqStanza:
      [self handleIqStanza:(GtalkIqStanza *)proto];
      break;
    default:
      [self didReceiveUnhandledProto:proto];
      break;
  }
}

// Called from secure socket once we have send the proto with given rmqId over the wire
// since we are mostly concerned with user facing messages which certainly have a rmqId
// we can retrieve them from the Rmq if necessary to look at stuff but for now we just
// log it.
- (void)secureSocket:(FIRMessagingSecureSocket *)socket
 didSendProtoWithTag:(int8_t)tag
               rmqId:(NSString *)rmqId {
  // log the message
  [self logMessage:rmqId messageType:tag isOut:YES];
}

#pragma mark - FIRMessagingTestConnection

- (void)sendProto:(GPBMessage *)proto {
  FIRMessagingProtoTag tag = FIRMessagingGetTagForProto(proto);
  if (tag == kFIRMessagingProtoTagLoginRequest && self.state != kFIRMessagingConnectionConnected) {
    FIRMessagingLoggerDebug(kFIRMessagingMessageCodeConnection006,
                            @"Cannot send generated message when the connection is not connected.");
    return;
  } else if (tag != kFIRMessagingProtoTagLoginRequest && self.state != kFIRMessagingConnectionSignedIn) {
    FIRMessagingLoggerDebug(kFIRMessagingMessageCodeConnection007,
                            @"Cannot send generated message when the connection is not signed in.");
    return;
  }

  _FIRMessagingDevAssert(self.socket != nil, @"Socket shouldn't be nil");
  if (self.socket == nil) {
    return;
  }

  [self willSendProto:proto];

  [self.socket sendData:proto.data withTag:tag rmqId:FIRMessagingGetRmq2Id(proto)];
}

- (void)sendOnConnectOrDrop:(GPBMessage *)message {
  if (self.state == kFIRMessagingConnectionSignedIn) {
    // If a connection has already been established, send normally
    [self sendProto:message];
  } else {
    // Otherwise add them to the list of messages to send after login
    [self.sendOnConnectMessages addObject:message];
  }
}

+ (GtalkLoginRequest *)loginRequestWithToken:(NSString *)token authID:(NSString *)authID {
  GtalkLoginRequest *login = [[GtalkLoginRequest alloc] init];
  login.accountId = 1000000;
  login.authService = GtalkLoginRequest_AuthService_AndroidId;
  login.authToken = token;
  login.id_p = [NSString stringWithFormat:@"%@-%@", @"ios", FIRMessagingCurrentLibraryVersion()];
  login.domain = @"mcs.android.com";
  login.deviceId = [NSString stringWithFormat:@"android-%llx", authID.longLongValue];
  login.networkType = [self currentNetworkType];
  login.resource = authID;
  login.user = authID;
  login.useRmq2 = YES;
  login.lastRmqId = 1; // Sending not enabled yet so this stays as 1.
  return login;
}

+ (int32_t)currentNetworkType {
  // http://developer.android.com/reference/android/net/ConnectivityManager.html
  int32_t fcmNetworkType;
  FIRMessagingNetworkStatus type = [[FIRMessaging messaging] networkType];
  switch (type) {
    case kFIRMessagingReachabilityReachableViaWiFi:
      fcmNetworkType = 1;
      break;

    case kFIRMessagingReachabilityReachableViaWWAN:
      fcmNetworkType = 0;
      break;

    default:
      fcmNetworkType = -1;
      break;
  }
  return fcmNetworkType;
}

- (void)sendLoginRequest:(NSString *)authId
                   token:(NSString *)token {
  GtalkLoginRequest *login = [[self class] loginRequestWithToken:token authID:authId];

  // clear the messages sent during last connection
  if ([self.d2sInfos count]) {
    [self.d2sInfos removeAllObjects];
  }

  if (self.unackedS2dIds.count > 0) {
    FIRMessagingLoggerDebug(
        kFIRMessagingMessageCodeConnection008,
        @"There are unacked persistent Ids in the login request: %@",
        [self.unackedS2dIds.description stringByReplacingOccurrencesOfString:@"%"
                                                                  withString:@"%%"]);
  }
  // Send out acks.
  for (NSString *unackedPersistentS2dId in self.unackedS2dIds) {
    [login.receivedPersistentIdArray addObject:unackedPersistentS2dId];
  }

  GtalkSetting *setting = [[GtalkSetting alloc] init];
  setting.name = @"new_vc";
  setting.value = @"1";
  [login.settingArray addObject:setting];

  [self sendProto:login];
}

- (void)sendHeartbeatAck {
  [self sendProto:[[GtalkHeartbeatAck alloc] init]];
}

- (void)sendHeartbeatPing {
  // cancel the previous heartbeat request.
  [NSObject cancelPreviousPerformRequestsWithTarget:self
                                           selector:@selector(sendHeartbeatPing)
                                             object:nil];
  [self scheduleConnectionTimeoutTask];
  [self sendProto:[[GtalkHeartbeatPing alloc] init]];
}

+ (GtalkIqStanza *)createStreamAck {
  GtalkIqStanza *iq = [[GtalkIqStanza alloc] init];
  iq.type = GtalkIqStanza_IqType_Set;
  iq.id_p = @"";
  GtalkExtension *ext = [[GtalkExtension alloc] init];
  ext.id_p = kIqStreamAck;
  ext.data_p = @"";
  iq.extension = ext;
  return iq;
}

- (void)sendStreamAck {
  GtalkIqStanza *iq = [[self class] createStreamAck];
  [self sendProto:iq];
}

- (void)sendClose {
  [self sendProto:[[GtalkClose alloc] init]];
}

- (void)handleIqStanza:(GtalkIqStanza *)iq {
  if (iq.hasExtension) {
    if (iq.extension.id_p == kIqStreamAck) {
      [self didReceiveStreamAck:iq];
      return;
    }
    if (iq.extension.id_p == kIqSelectiveAck) {
      [self didReceiveSelectiveAck:iq];
      return;
    }
    FIRMessagingLoggerDebug(kFIRMessagingMessageCodeConnection009, @"Unknown ack extension id %d.",
                            iq.extension.id_p);
  } else {
    FIRMessagingLoggerDebug(kFIRMessagingMessageCodeConnection010, @"Ip stanza without extension.");
  }
  [self didReceiveUnhandledProto:iq];
}

- (void)didReceiveLoginResponse:(GtalkLoginResponse *)loginResponse {
  if (loginResponse.hasError) {
    FIRMessagingLoggerDebug(kFIRMessagingMessageCodeConnection011,
                            @"Login error with type: %@, message: %@.", loginResponse.error.type,
                            loginResponse.error.message);
    return;
  }
  FIRMessagingLoggerDebug(kFIRMessagingMessageCodeConnection012, @"Logged onto MCS service.");
  // We sent the persisted list of unack'd messages with login so we can assume they have been ack'd
  // by the server.
  _FIRMessagingDevAssert(self.unackedS2dIds.count == 0, @"No ids present");
  _FIRMessagingDevAssert(self.outStreamId == 1, @"Login should be the first stream id");

  self.state = kFIRMessagingConnectionSignedIn;
  self.lastLoginServerTimestamp = loginResponse.serverTimestamp;
  [self.delegate didLoginWithConnection:self];
  [self sendHeartbeatPing];

  // Add all the TTL=0 messages on connect
  for (GPBMessage *message in self.sendOnConnectMessages) {
    [self sendProto:message];
  }
  [self.sendOnConnectMessages removeAllObjects];
}

- (void)didReceiveHeartbeatPing:(GtalkHeartbeatPing *)heartbeatPing {
  [self sendHeartbeatAck];
}

- (void)didReceiveHeartbeatAck:(GtalkHeartbeatAck *)heartbeatAck {
}

- (void)didReceiveDataMessageStanza:(GtalkDataMessageStanza *)dataMessageStanza {
  // TODO: Maybe add support raw data later
  [self.delegate connectionDidRecieveMessage:dataMessageStanza];
}

- (void)didReceiveUnhandledProto:(GPBMessage *)proto {
  FIRMessagingLoggerDebug(kFIRMessagingMessageCodeConnection013, @"Received unhandled proto");
}

- (void)didReceiveStreamAck:(GtalkIqStanza *)iq {
  // Server received some stuff from us we don't really need to do anything special
}

- (void)didReceiveSelectiveAck:(GtalkIqStanza *)iq {
  GtalkExtension *extension = iq.extension;
  if (extension) {
    int extensionId = extension.id_p;
    if (extensionId == kIqSelectiveAck) {

      NSString *dataString = extension.data_p;
      GtalkSelectiveAck *selectiveAck = [[GtalkSelectiveAck alloc] init];
      [selectiveAck mergeFromData:[dataString dataUsingEncoding:NSUTF8StringEncoding]
                extensionRegistry:nil];

      NSArray <NSString *>*acks = [selectiveAck idArray];

      // we've received ACK's
      [self.delegate connectionDidReceiveAckForRmqIds:acks];

      // resend unacked messages
      [self.dataMessageManager resendMessagesWithConnection:self];
    }
  }
}

- (void)didReceiveClose:(GtalkClose *)close {
  [self disconnect];
}

- (void)willProcessProto:(GPBMessage *)proto {
  self.inStreamId++;

  if ([proto isKindOfClass:GtalkDataMessageStanza.class]) {
    FIRMessagingLoggerDebug(kFIRMessagingMessageCodeConnection014,
                            @"RMQ: Receiving %@ with rmq_id: %@ incoming stream Id: %d",
                            proto.class, FIRMessagingGetRmq2Id(proto), self.inStreamId);
  } else {
    FIRMessagingLoggerDebug(kFIRMessagingMessageCodeConnection015,
                            @"RMQ: Receiving %@ with incoming stream Id: %d.", proto.class,
                            self.inStreamId);
  }
  int streamId = FIRMessagingGetLastStreamId(proto);
  if (streamId != kInvalidStreamId) {
    // confirm the D2S messages that were sent by us
    [self confirmAckedD2sIdsWithStreamId:streamId];

    // We can now confirm that our ack was received by the server and start our unack'd list fresh
    // with the proto we just received.
    [self confirmAckedS2dIdsWithStreamId:streamId];
  }
  NSString *rmq2Id = FIRMessagingGetRmq2Id(proto);
  if (rmq2Id != nil) {
    FIRMessagingLoggerDebug(kFIRMessagingMessageCodeConnection016,
                            @"RMQ: Add unacked persistent Id: %@.",
                            [rmq2Id stringByReplacingOccurrencesOfString:@"%" withString:@"%%"]);
    [self.unackedS2dIds addObject:rmq2Id];
    [self.rmq2Manager saveS2dMessageWithRmqId:rmq2Id]; // RMQ save
  }
  BOOL explicitAck = ([proto isKindOfClass:[GtalkDataMessageStanza class]] &&
                      [(GtalkDataMessageStanza *)proto immediateAck]);
  // If we have not sent anything and the ack threshold has been reached then explicitly send one
  // to notify the server that we have received messages.
  if (self.inStreamId - self.lastStreamIdAcked >= kAckingInterval || explicitAck) {
    [self sendStreamAck];
  }
}

- (void)willSendProto:(GPBMessage *)proto {
  self.outStreamId++;

  NSString *rmq2Id = FIRMessagingGetRmq2Id(proto);
  if ([rmq2Id length]) {
    FIRMessagingD2SInfo *d2sInfo = [[FIRMessagingD2SInfo alloc] initWithStreamId:self.outStreamId d2sId:rmq2Id];
    [self.d2sInfos addObject:d2sInfo];
  }

  // each time we send a d2s message, it acks previously received
  // s2d messages via the last (s2d) stream id received.

  FIRMessagingLoggerDebug(kFIRMessagingMessageCodeConnection017,
                          @"RMQ: Sending %@ with outgoing stream Id: %d.", proto.class,
                          self.outStreamId);
  // We have received messages since last time we sent something - send ack info to server.
  if (self.inStreamId > self.lastStreamIdAcked) {
    FIRMessagingSetLastStreamId(proto, self.inStreamId);
    self.lastStreamIdAcked = self.inStreamId;
  }

  if (self.unackedS2dIds.count > 0) {
    // Move all 'unack'd' messages to the ack'd map so they can be removed once the
    // ack is confirmed.
    NSArray *ackedS2dIds = [NSArray arrayWithArray:self.unackedS2dIds];
    FIRMessagingLoggerDebug(
        kFIRMessagingMessageCodeConnection018, @"RMQ: Mark persistent Ids as acked: %@.",
        [ackedS2dIds.description stringByReplacingOccurrencesOfString:@"%" withString:@"%%"]);
    [self.unackedS2dIds removeAllObjects];
    self.ackedS2dMap[[@(self.outStreamId) stringValue]] = ackedS2dIds;
  }
}

#pragma mark - Private

/**
 * This processes the s2d message received in reference to the d2s messages
 * that we have sent before.
 */
- (void)confirmAckedD2sIdsWithStreamId:(int)lastReceivedStreamId {
  NSMutableArray *d2sIdsAcked = [NSMutableArray array];
  for (FIRMessagingD2SInfo *d2sInfo in self.d2sInfos) {
    if (lastReceivedStreamId < d2sInfo.streamId) {
      break;
    }
    [d2sIdsAcked addObject:d2sInfo];
  }

  NSMutableArray *rmqIds = [NSMutableArray arrayWithCapacity:[d2sIdsAcked count]];
  // remove ACK'ed messages
  for (FIRMessagingD2SInfo *d2sInfo in d2sIdsAcked) {
    if ([d2sInfo.d2sID length]) {
      [rmqIds addObject:d2sInfo.d2sID];
    }
    [self.d2sInfos removeObject:d2sInfo];
  }
  [self.delegate connectionDidReceiveAckForRmqIds:rmqIds];
  int count = [self.delegate connectionDidReceiveAckForRmqIds:rmqIds];
  if (kMessageRemoveAckThresholdCount > 0 && count >= kMessageRemoveAckThresholdCount) {
    // For short lived connections, if a large number of messages are removed, send an
    // ack straight away so the server knows that this message was received.
    [self sendStreamAck];
  }
}

/**
 * Called when a stream ACK or a selective ACK are received - this indicates the message has
 * been received by MCS.
 */
- (void)didReceiveAckForRmqIds:(NSArray *)rmqIds {
  // TODO: let the user know that the following messages were received by the server
}

- (void)confirmAckedS2dIdsWithStreamId:(int)lastReceivedStreamId {
  // If the server hasn't received the streamId yet.
  FIRMessagingLoggerDebug(kFIRMessagingMessageCodeConnection019,
                          @"RMQ: Server last received stream Id: %d.", lastReceivedStreamId);
  if (lastReceivedStreamId < self.outStreamId) {
    // TODO: This could be a good indicator that we need to re-send something (acks)?
    FIRMessagingLoggerDebug(kFIRMessagingMessageCodeConnection020,
                            @"RMQ: There are unsent messages that should be send...\n"
                             "server received: %d\nlast stream id sent: %d",
                            lastReceivedStreamId, self.outStreamId);
  }

  NSSet *ackedStreamIds =
    [self.ackedS2dMap keysOfEntriesPassingTest:^BOOL(id key, id obj, BOOL *stop) {
      NSString *streamId = key;
      return streamId.intValue <= lastReceivedStreamId;
    }];
  NSMutableArray *s2dIdsToDelete = [NSMutableArray array];

  for (NSString *streamId in ackedStreamIds) {
    NSArray *ackedS2dIds = self.ackedS2dMap[streamId];
    if (ackedS2dIds.count > 0) {
      FIRMessagingLoggerDebug(
          kFIRMessagingMessageCodeConnection021,
          @"RMQ: Mark persistent Ids as confirmed by stream id %@: %@.", streamId,
          [ackedS2dIds.description stringByReplacingOccurrencesOfString:@"%" withString:@"%%"]);
      [self.ackedS2dMap removeObjectForKey:streamId];
    }

    [s2dIdsToDelete addObjectsFromArray:ackedS2dIds];
  }

  // clean up s2d ids that the server knows we've received.
  // we let the server know via a s2d last stream id received in a
  // d2s message. the server lets us know it has received our d2s
  // message via a d2s last stream id received in a s2d message.
  [self.rmq2Manager removeS2dIds:s2dIdsToDelete];
}

- (void)resetUnconfirmedAcks {
  [self.ackedS2dMap enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) {
    [self.unackedS2dIds addObjectsFromArray:obj];
  }];
  [self.ackedS2dMap removeAllObjects];
}

- (void)disconnect {
  _FIRMessagingDevAssert(self.state != kFIRMessagingConnectionNotConnected, @"Connection already not connected");
  // cancel pending timeout tasks.
  [self cancelConnectionTimeoutTask];
  // cancel pending heartbeat.
  [NSObject cancelPreviousPerformRequestsWithTarget:self
                                           selector:@selector(sendHeartbeatPing)
                                             object:nil];
  // Unset the delegate. FIRMessagingConnection will not receive further events from the socket from now on.
  self.socket.delegate = nil;
  [self.socket disconnect];
  self.state = kFIRMessagingConnectionNotConnected;
}

- (void)connectionTimedOut {
  FIRMessagingLoggerDebug(kFIRMessagingMessageCodeConnection022,
                          @"Connection to FIRMessaging service timed out.");
  [self disconnect];
  [self.delegate connection:self didCloseForReason:kFIRMessagingConnectionCloseReasonTimeout];
}

- (void)scheduleConnectionTimeoutTask {
  // cancel the previous heartbeat timeout event and schedule a new one.
  [self cancelConnectionTimeoutTask];
  [self performSelector:@selector(connectionTimedOut)
             withObject:nil
             afterDelay:[self connectionTimeoutInterval]];
}

- (void)cancelConnectionTimeoutTask {
  // cancel pending timeout tasks.
  [NSObject cancelPreviousPerformRequestsWithTarget:self
                                           selector:@selector(connectionTimedOut)
                                             object:nil];
}

- (void)logMessage:(NSString *)description messageType:(int)messageType isOut:(BOOL)isOut {
  messageType = isOut ? -messageType : messageType;
  FIRMessagingLoggerDebug(kFIRMessagingMessageCodeConnection023,
                          @"Send msg: %@ type: %d inStreamId: %d outStreamId: %d", description,
                          messageType, self.inStreamId, self.outStreamId);
}

- (NSTimeInterval)connectionTimeoutInterval {
  return kConnectionTimeout;
}

@end
