import 'dart:async';
import 'dart:io';

import 'package:clock/clock.dart';
import 'package:drift/drift.dart';
import 'package:hashlib/random.dart';
import 'package:mutex/mutex.dart';

import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/database/twonly.db.dart' hide Message;
import 'package:twonly/src/model/protobuf/api/websocket/client_to_server.pb.dart'
    as client;
import 'package:twonly/src/model/protobuf/api/websocket/client_to_server.pb.dart';
import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pb.dart'
    as server;
import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pbserver.dart';
import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart';
import 'package:twonly/src/services/api/client2client/additional_data.c2c.dart';
import 'package:twonly/src/services/api/client2client/contact.c2c.dart';
import 'package:twonly/src/services/api/client2client/errors.c2c.dart';
import 'package:twonly/src/services/api/client2client/groups.c2c.dart';
import 'package:twonly/src/services/api/client2client/media.c2c.dart';
import 'package:twonly/src/services/api/client2client/messages.c2c.dart';
import 'package:twonly/src/services/api/client2client/prekeys.c2c.dart';
import 'package:twonly/src/services/api/client2client/pushkeys.c2c.dart';
import 'package:twonly/src/services/api/client2client/reaction.c2c.dart';
import 'package:twonly/src/services/api/client2client/text_message.c2c.dart';
import 'package:twonly/src/services/api/client2client/user_discovery.c2c.dart';
import 'package:twonly/src/services/api/messages.api.dart';
import 'package:twonly/src/services/group.service.dart';
import 'package:twonly/src/services/key_verification.service.dart';
import 'package:twonly/src/services/notifications/background.notifications.dart';
import 'package:twonly/src/services/notifications/fcm.notifications.dart';
import 'package:twonly/src/services/passwordless_recovery.service.dart';
import 'package:twonly/src/services/signal/encryption.signal.dart';
import 'package:twonly/src/services/signal/session.signal.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';

Future<void> handleServerMessage(server.ServerToClient msg) async {
  Log.info('Processing a message from the server.');

  /// Returns means, that the server can delete the message from the server.
  final ok = client.Response_Ok()..none = true;
  var response = client.Response()..ok = ok;

  try {
    if (msg.v0.hasRequestNewPreKeys()) {
      response = await handleRequestNewPreKey();
    } else if (msg.v0.hasRequestNewPqcPreKeys()) {
      response = (await handleRequestNewPqcPreKey()) ?? response;
    } else if (msg.v0.hasNewMessage()) {
      Log.info('Got 1 message from the server.');
      await handleClient2ClientMessage(msg.v0.newMessage);
    } else if (msg.v0.hasNewMessages()) {
      Log.info(
        'Got ${msg.v0.newMessages.newMessages.length} messages from the server.',
      );
      final brokenSessionsInCurrentBatch = <int>{};
      for (final newMessage in msg.v0.newMessages.newMessages) {
        try {
          await handleClient2ClientMessage(
            newMessage,
            brokenSessionsInCurrentBatch: brokenSessionsInCurrentBatch,
          );
        } catch (e) {
          Log.error(e);
        }
      }
    } else {
      Log.error('Unknown server message: $msg');
    }
  } catch (e) {
    Log.error(e);
  }

  final v0 = client.V0()
    ..seq = msg.v0.seq
    ..response = response;

  final responseSent = await apiService.sendResponse(ClientToServer()..v0 = v0);
  if (responseSent) {
    Log.info(
      'Successfully queued response for server message ${msg.v0.seq}.',
    );
  } else {
    Log.warn('Could not send response for server message ${msg.v0.seq}.');
  }
  AppState.gotMessageFromServer = true;
  Log.info('All messages from the server processed.');
}

DateTime lastPushKeyRequest = clock.now().subtract(const Duration(hours: 1));

final Map<String, Mutex> _messageLocks = {};

Future<void> handleClient2ClientMessage(
  NewMessage newMessage, {
  Set<int>? brokenSessionsInCurrentBatch,
}) async {
  final body = Uint8List.fromList(newMessage.body);
  final message = Message.fromBuffer(body);
  final receiptId = message.receiptId;

  final mutex = _messageLocks.putIfAbsent(receiptId, Mutex.new);
  if (mutex.isLocked) {
    Log.info(
      '[$receiptId] Skipping — already being processed by another handler',
    );
    return;
  }
  await mutex.protect(() async {
    try {
      await _handleClient2ClientMessage(
        newMessage,
        message,
        brokenSessionsInCurrentBatch: brokenSessionsInCurrentBatch,
      );
    } finally {
      _messageLocks.remove(receiptId);
    }
  });
}

Future<void> _handleClient2ClientMessage(
  NewMessage newMessage,
  Message message, {
  Set<int>? brokenSessionsInCurrentBatch,
}) async {
  final fromUserId = newMessage.fromUserId.toInt();
  final receiptId = message.receiptId;

  if (brokenSessionsInCurrentBatch?.contains(fromUserId) == true) {
    // This happens when a session goes out of sync (e.g. wrong message order).
    // We skip the remaining messages in the batch because each failed decryption
    // attempt is extremely slow (~1.2s) and would otherwise freeze the app.
    // By returning early, we skip gotReceipt() and error responses.
    // The server still deletes the batch since we ACK the entire batch later.
    // The sender keeps the message unacknowledged. Once they process our SESSION_OUT_OF_SYNC
    // error and establish a new session, their retry logic will automatically re-encrypt
    // and re-send these messages with the new keys.
    Log.info(
      'Skipping message from $fromUserId - session known broken in this batch',
    );
    return;
  }

  if (await twonlyDB.receiptsDao.isDuplicated(receiptId)) {
    if (message.type == Message_Type.SENDER_DELIVERY_RECEIPT) {
      Log.info(
        '[$receiptId] Delivery receipt is a duplicate. Skipping receipt response.',
      );
      return;
    }
    const duplicateReceiptCooldown = Duration(days: 10);
    final shouldResend = await twonlyDB.receiptsDao.claimDuplicateReceiptResend(
      receiptId,
      duplicateReceiptCooldown,
    );
    if (!shouldResend) {
      Log.info(
        '[$receiptId] Message is a duplicate. Skipping delivery receipt during cooldown.',
      );
      return;
    }
    Log.info(
      '[$receiptId] Message is a duplicate and cooldown elapsed. Sending delivery receipt again.',
    );
    try {
      final response = Message(type: Message_Type.SENDER_DELIVERY_RECEIPT);
      await twonlyDB.receiptsDao.insertReceipt(
        ReceiptsCompanion(
          receiptId: Value(receiptId),
          contactId: Value(fromUserId),
          message: Value(response.writeToBuffer()),
          contactWillSendsReceipt: const Value(false),
        ),
      );
    } catch (e) {
      Log.warn('[$receiptId] Error handling duplicate receipt ACK: $e');
    }
    await tryToSendCompleteMessage(
      receiptId: receiptId,
      blocking: false,
    );
    return;
  }

  Log.info('[$receiptId] Started processing message');

  switch (message.type) {
    case Message_Type.SENDER_DELIVERY_RECEIPT:
      Log.info('[$receiptId] Got delivery receipt!');
      await twonlyDB.receiptsDao.confirmReceipt(receiptId, fromUserId);

    case Message_Type.PLAINTEXT_CONTENT:
      var retry = false;
      if (message.hasPlaintextContent()) {
        if (message.plaintextContent.hasDecryptionErrorMessage()) {
          if (message.plaintextContent.decryptionErrorMessage.type ==
              PlaintextContent_DecryptionErrorMessage_Type.PREKEY_UNKNOWN) {
            // Get a new prekey from the server, and establish a new signal session.
            await handleSessionResync(fromUserId);
          }
          Log.info(
            '[$receiptId] Got decryption error: ${message.plaintextContent.decryptionErrorMessage.type}',
          );
          retry = true;
        }
        if (message.plaintextContent.hasRetryControlError()) {
          Log.info(
            '[$receiptId] Got access control error. Resending message.',
          );
          retry = true;
        }
      }

      if (retry) {
        final newReceiptId = uuid.v4();
        await twonlyDB.receiptsDao.updateReceipt(
          receiptId,
          ReceiptsCompanion(
            receiptId: Value(newReceiptId),
            ackByServerAt: const Value(null),
          ),
        );
        Log.info(
          '[$receiptId] Sending error message to the original sender with receiptId $newReceiptId.',
        );
        await tryToSendCompleteMessage(
          receiptId: newReceiptId,
          blocking: false,
        );
      }

    case Message_Type.CIPHERTEXT:
    case Message_Type.CIPHERTEXT_V2:
    case Message_Type.PREKEY_BUNDLE:
      if (message.hasEncryptedContent()) {
        Value<String>? receiptIdDB;

        final encryptedContentRaw = Uint8List.fromList(
          message.encryptedContent,
        );

        Message? response;

        final user = await twonlyDB.contactsDao
            .getContactByUserId(fromUserId)
            .getSingleOrNull();

        if (user == null) {
          if (!await addNewHiddenContact(fromUserId)) {
            // in case the user could not be added, send a retry error message as this error should only happen in case
            // it was not possible to load the user from the server
            response = Message(
              receiptId: receiptId,
              type: Message_Type.PLAINTEXT_CONTENT,
              plaintextContent: PlaintextContent(
                retryControlError: PlaintextContent_RetryErrorMessage(),
              ),
            );
          }
        }

        if (response == null) {
          final (
            encryptedContent,
            plainTextContent,
          ) = await handleEncryptedMessageRaw(
            fromUserId,
            encryptedContentRaw,
            message.type,
            receiptId,
            brokenSessionsInCurrentBatch: brokenSessionsInCurrentBatch,
          );
          if (plainTextContent != null) {
            response = Message(
              receiptId: receiptId,
              type: Message_Type.PLAINTEXT_CONTENT,
              plaintextContent: plainTextContent,
            );
          } else if (encryptedContent != null) {
            response = Message(
              type: Message_Type.CIPHERTEXT,
              encryptedContent: encryptedContent.writeToBuffer(),
            );
            // Use Value.absent() for CIPHERTEXT messages so that insertReceipt generates a new UUID.
            // This prevents receipt ID collisions and ensures the recipient's ACK is tracked correctly.
            receiptIdDB = const Value.absent();
          } else {
            // Message was successful processed
          }
        }

        response ??= Message(type: Message_Type.SENDER_DELIVERY_RECEIPT);

        String? targetReceiptId;
        try {
          final inserted = await twonlyDB.receiptsDao.insertReceipt(
            ReceiptsCompanion(
              receiptId: receiptIdDB ?? Value(receiptId),
              contactId: Value(fromUserId),
              message: Value(response.writeToBuffer()),
              contactWillSendsReceipt: const Value(false),
            ),
          );
          // Use the inserted receipt's ID because for CIPHERTEXT messages we generate a new UUID
          // (receiptIdDB is Value.absent()) to avoid ID collisions and properly track individual ACKs.
          targetReceiptId = inserted?.receiptId;
        } catch (e) {
          Log.warn('[$receiptId] Error inserting receipt: $e');
        }

        targetReceiptId ??= receiptIdDB?.value ?? receiptId;

        await tryToSendCompleteMessage(
          receiptId: targetReceiptId,
          blocking: false,
        );
      }
    case Message_Type.TEST_NOTIFICATION:
      break;
  }

  try {
    await twonlyDB.receiptsDao.gotReceipt(receiptId);
    Log.info('[$receiptId] Finished processing');
  } catch (e) {
    Log.warn('[$receiptId] Error marking message as received: $e');
    Log.error(
      'Error marking message as received: $e',
      onlyIfSentryEnabled: true,
    );
  }
}

Future<(EncryptedContent?, PlaintextContent?)> handleEncryptedMessageRaw(
  int fromUserId,
  Uint8List encryptedContentRaw,
  Message_Type messageType,
  String receiptId, {
  Set<int>? brokenSessionsInCurrentBatch,
}) async {
  Log.info('[$receiptId] calling signalDecryptMessage');
  EncryptedContent? encryptedContent;
  PlaintextContent_DecryptionErrorMessage_Type? decryptionErrorType;

  if (messageType == Message_Type.CIPHERTEXT_V2) {
    (encryptedContent, decryptionErrorType) = await signalDecryptMessageV2(
      fromUserId,
      encryptedContentRaw,
      brokenSessionsInCurrentBatch: brokenSessionsInCurrentBatch,
    );
  } else {
    (encryptedContent, decryptionErrorType) = await signalDecryptMessageV1(
      fromUserId,
      encryptedContentRaw,
      messageType.value,
      brokenSessionsInCurrentBatch: brokenSessionsInCurrentBatch,
    );
  }

  if (encryptedContent == null) {
    return (
      null,
      PlaintextContent(
        decryptionErrorMessage: PlaintextContent_DecryptionErrorMessage(
          type: decryptionErrorType ??=
              PlaintextContent_DecryptionErrorMessage_Type.UNKNOWN,
        ),
      ),
    );
  }

  Log.info('[$receiptId] Calling handleEncryptedMessage');

  final result = await handleEncryptedMessage(
    fromUserId,
    encryptedContent,
    messageType,
    receiptId,
  );

  Log.info('[$receiptId] Finished handleEncryptedMessage');

  if (result.responseCipherText == null && result.responsePlaintext == null) {
    unawaited(FcmNotificationService.updateLastServerMessageTimestamp());
    if (Platform.isAndroid && result.showPushNotification) {
      // Message was handled without any error. Show push notification to the user for Android.
      await showPushNotificationFromServerMessages(
        fromUserId,
        encryptedContent,
      );
    }
  }

  return (result.responseCipherText, result.responsePlaintext);
}

Future<DecryptedMessageResult> handleEncryptedMessage(
  int fromUserId,
  EncryptedContent content,
  Message_Type messageType,
  String receiptId,
) async {
  // We got a valid message fromUserId, so mark all messages which where
  // send to the user but not yet ACK for retransmission. All marked messages
  // will be either transmitted again after a new server connection (minimum 20 seconds).
  // In case the server sends the ACK before they will be deleted.
  // This ensures that 1. all messages will be received by the other person and
  // that they will be retransmitted in case the server deleted them as they
  // where not downloaded within the 40 days
  await twonlyDB.receiptsDao.markMessagesForRetry(fromUserId);

  final senderProfileCounter = await checkForProfileUpdate(fromUserId, content);
  if (userService.currentUser.isUserDiscoveryEnabled &&
      content.hasSenderUserDiscoveryVersion()) {
    await checkForUserDiscoveryChanges(
      fromUserId,
      content.senderUserDiscoveryVersion,
      receiptId,
    );
  }

  if (content.hasAskForFriendPromotions() && content.askForFriendPromotions) {
    final contact = await twonlyDB.contactsDao.getContactById(fromUserId);
    if (contact != null && contact.askForFriendPromotions == null) {
      await twonlyDB.contactsDao.updateContact(
        fromUserId,
        const ContactsCompanion(askForFriendPromotions: Value(true)),
      );
    }
  }

  if (content.hasContactRequest()) {
    if (!await handleContactRequest(
      fromUserId,
      content.contactRequest,
      receiptId,
    )) {
      return DecryptedMessageResult(
        responsePlaintext: PlaintextContent()
          ..retryControlError = PlaintextContent_RetryErrorMessage(),
      );
    }
    return const DecryptedMessageResult();
  }

  if (content.hasErrorMessages()) {
    await handleErrorMessage(
      fromUserId,
      content.errorMessages,
      receiptId,
      groupId: content.hasGroupId() ? content.groupId : null,
    );
    return const DecryptedMessageResult(showPushNotification: false);
  }

  if (content.hasPasswordlessRecovery()) {
    await PasswordlessRecoveryService.handlePasswordlessRecovery(
      fromUserId,
      content.passwordlessRecovery,
      receiptId,
    );
    return const DecryptedMessageResult();
  }

  if (content.hasPasswordlessRecoveryHeartbeat()) {
    await PasswordlessRecoveryService.handlePasswordlessRecoveryHeartbeat(
      fromUserId,
      content.passwordlessRecoveryHeartbeat,
      receiptId,
    );
    return const DecryptedMessageResult();
  }

  if (content.hasContactUpdate()) {
    await handleContactUpdate(
      fromUserId,
      content.contactUpdate,
      senderProfileCounter,
      receiptId,
    );
    return const DecryptedMessageResult(showPushNotification: false);
  }

  if (content.hasUserDiscoveryRequest()) {
    await handleUserDiscoveryRequest(
      fromUserId,
      content.userDiscoveryRequest,
      receiptId,
    );
    return const DecryptedMessageResult(showPushNotification: false);
  }

  if (content.hasUserDiscoveryUpdate()) {
    await handleUserDiscoveryUpdate(
      fromUserId,
      content.userDiscoveryUpdate,
      receiptId,
    );
    return const DecryptedMessageResult();
  }

  if (content.hasPushKeys()) {
    await handlePushKey(fromUserId, content.pushKeys, receiptId);
    return const DecryptedMessageResult();
  }

  if (content.hasMessageUpdate()) {
    await handleMessageUpdate(
      fromUserId,
      content.messageUpdate,
      receiptId,
    );
    return const DecryptedMessageResult();
  }

  if (content.hasKeyVerificationProof()) {
    await KeyVerificationService.handleVerificationProof(
      fromUserId,
      content.keyVerificationProof.calculatedMac,
    );
    return const DecryptedMessageResult();
  }

  if (content.hasMediaUpdate()) {
    await handleMediaUpdate(
      fromUserId,
      content.mediaUpdate,
      receiptId,
    );
    return const DecryptedMessageResult();
  }

  if (!content.hasGroupId()) {
    final type = _getEncryptedContentType(content);
    Log.warn(
      '[$receiptId] Messages should have a groupId $fromUserId. Type: $type',
    );
    Log.error(
      'Messages should have a groupId. Type: $type',
      onlyIfSentryEnabled: true,
    );
    return const DecryptedMessageResult();
  }

  if (content.hasGroupCreate()) {
    await handleGroupCreate(
      fromUserId,
      content.groupId,
      content.groupCreate,
      receiptId,
    );
    return const DecryptedMessageResult();
  }

  /// Verify that the user is (still) in that group...
  if (!await twonlyDB.groupsDao.isContactInGroup(fromUserId, content.groupId)) {
    // Check if this is a direct chat...
    if (getUUIDforDirectChat(userService.currentUser.userId, fromUserId) ==
        content.groupId) {
      final contact = await twonlyDB.contactsDao
          .getContactByUserId(fromUserId)
          .getSingleOrNull();
      Log.info(
        '[$receiptId] Contact exists?: ${contact != null} Is deleted? ${contact?.deletedByUser} Accepted? (${contact?.accepted})',
      );
      if (contact == null || !contact.accepted || contact.deletedByUser) {
        await handleNewContactRequest(fromUserId);
        Log.warn(
          '[$receiptId] User tries to send message to direct chat while the user does not exist!',
        );
        return DecryptedMessageResult(
          responseCipherText: EncryptedContent(
            errorMessages: EncryptedContent_ErrorMessages(
              type: EncryptedContent_ErrorMessages_Type
                  .ERROR_PROCESSING_MESSAGE_CREATED_ACCOUNT_REQUEST_INSTEAD,
              relatedReceiptId: receiptId,
            ),
          ),
        );
      }
      Log.info(
        '[$receiptId] Creating new DirectChat between two users',
      );
      await twonlyDB.groupsDao.createNewDirectChat(
        fromUserId,
        GroupsCompanion(
          groupName: Value(getContactDisplayName(contact)),
        ),
      );
    } else {
      if (content.hasGroupJoin()) {
        Log.warn(
          '[$receiptId] Got group join message, but group does not exist yet, retry later. As probably the GroupCreate was not yet received.',
        );
        // In case the group join was received before the GroupCreate the sender should send it later again.
        return DecryptedMessageResult(
          responsePlaintext: PlaintextContent()
            ..retryControlError = PlaintextContent_RetryErrorMessage(),
        );
      }

      Log.warn(
        '[$receiptId] User $fromUserId tried to access group ${content.groupId}. Sending GROUP_NOT_FOUND_OR_NOT_A_MEMBER error.',
      );
      return DecryptedMessageResult(
        responseCipherText: EncryptedContent(
          groupId: content.groupId,
          errorMessages: EncryptedContent_ErrorMessages(
            type: EncryptedContent_ErrorMessages_Type
                .GROUP_NOT_FOUND_OR_NOT_A_MEMBER,
            relatedReceiptId: receiptId,
          ),
        ),
      );
    }
  }

  if (content.hasFlameSync()) {
    await handleFlameSync(content.groupId, content.flameSync, receiptId);
    return const DecryptedMessageResult();
  }

  if (content.hasGroupUpdate()) {
    await handleGroupUpdate(
      fromUserId,
      content.groupId,
      content.groupUpdate,
      receiptId,
    );
    return const DecryptedMessageResult();
  }

  if (content.hasGroupJoin()) {
    if (!await handleGroupJoin(
      fromUserId,
      content.groupId,
      content.groupJoin,
      receiptId,
    )) {
      return DecryptedMessageResult(
        responsePlaintext: PlaintextContent()
          ..retryControlError = PlaintextContent_RetryErrorMessage(),
      );
    }
    return const DecryptedMessageResult();
  }

  if (content.hasResendGroupPublicKey()) {
    await handleResendGroupPublicKey(
      fromUserId,
      content.groupId,
      content.groupJoin,
      receiptId,
    );
    return const DecryptedMessageResult();
  }

  if (content.hasAdditionalDataMessage()) {
    await handleAdditionalDataMessage(
      fromUserId,
      content.groupId,
      content.additionalDataMessage,
      receiptId,
    );
    return const DecryptedMessageResult();
  }

  if (content.hasTextMessage()) {
    final isNewText = await handleTextMessage(
      fromUserId,
      content.groupId,
      content.textMessage,
      receiptId,
    );
    return DecryptedMessageResult(showPushNotification: isNewText);
  }

  if (content.hasReaction()) {
    await handleReaction(
      fromUserId,
      content.groupId,
      content.reaction,
      receiptId,
    );
    return const DecryptedMessageResult();
  }

  if (content.hasMedia()) {
    final isNewMedia = await handleMedia(
      fromUserId,
      content.groupId,
      content.media,
      receiptId,
    );
    return DecryptedMessageResult(showPushNotification: isNewMedia);
  }

  if (content.hasTypingIndicator()) {
    await handleTypingIndicator(
      fromUserId,
      content.groupId,
      content.typingIndicator,
      receiptId,
    );
  }

  return const DecryptedMessageResult();
}

String _getEncryptedContentType(EncryptedContent content) {
  if (content.hasMessageUpdate()) return 'messageUpdate';
  if (content.hasMedia()) return 'media';
  if (content.hasMediaUpdate()) return 'mediaUpdate';
  if (content.hasContactUpdate()) return 'contactUpdate';
  if (content.hasContactRequest()) return 'contactRequest';
  if (content.hasFlameSync()) return 'flameSync';
  if (content.hasPushKeys()) return 'pushKeys';
  if (content.hasReaction()) return 'reaction';
  if (content.hasTextMessage()) return 'textMessage';
  if (content.hasGroupCreate()) return 'groupCreate';
  if (content.hasGroupJoin()) return 'groupJoin';
  if (content.hasGroupUpdate()) return 'groupUpdate';
  if (content.hasResendGroupPublicKey()) return 'resendGroupPublicKey';
  if (content.hasErrorMessages()) return 'errorMessages';
  if (content.hasAdditionalDataMessage()) return 'additionalDataMessage';
  if (content.hasTypingIndicator()) return 'typingIndicator';
  if (content.hasUserDiscoveryRequest()) return 'userDiscoveryRequest';
  if (content.hasUserDiscoveryUpdate()) return 'userDiscoveryUpdate';
  if (content.hasKeyVerificationProof()) return 'keyVerificationProof';
  return 'unknown';
}

class DecryptedMessageResult {
  const DecryptedMessageResult({
    this.responseCipherText,
    this.responsePlaintext,
    this.showPushNotification = true,
  });
  final EncryptedContent? responseCipherText;
  final PlaintextContent? responsePlaintext;
  final bool showPushNotification;
}
