import 'package:clock/clock.dart';
import 'package:drift/drift.dart';
import 'package:hashlib/random.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/tables/contacts.table.dart';
import 'package:twonly/src/database/tables/groups.table.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/tables/messages.table.dart';
import 'package:twonly/src/database/tables/reactions.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/utils/log.dart';

part 'messages.dao.g.dart';

@DriftAccessor(
  tables: [
    Messages,
    Contacts,
    MediaFiles,
    Reactions,
    MessageHistories,
    GroupMembers,
    MessageActions,
    Groups,
  ],
)
class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
  // this constructor is required so that the main database can create an instance
  // of this object.
  // ignore: matching_super_parameters
  MessagesDao(super.db);

  Stream<List<Message>> watchMessageNotOpened(String groupId) {
    final query =
        select(messages).join([
            leftOuterJoin(
              mediaFiles,
              mediaFiles.mediaId.equalsExp(messages.mediaId),
            ),
          ])
          ..where(
            messages.openedAt.isNull() &
                messages.groupId.equals(groupId) &
                messages.isDeletedFromSender.equals(false) &
                (messages.mediaId.isNull() |
                    mediaFiles.downloadState.isNull() |
                    mediaFiles.downloadState
                        .equals(DownloadState.reuploadRequested.name)
                        .not()),
          )
          ..orderBy([OrderingTerm.desc(messages.createdAt)]);
    return query.map((row) => row.readTable(messages)).watch();
  }

  Stream<List<Message>> watchMediaNotOpened(String groupId) {
    final query =
        select(messages).join([
            leftOuterJoin(
              mediaFiles,
              mediaFiles.mediaId.equalsExp(messages.mediaId),
            ),
          ])
          ..where(
            (mediaFiles.downloadState.isNull() |
                    mediaFiles.downloadState
                        .equals(DownloadState.reuploadRequested.name)
                        .not()) &
                mediaFiles.type.equals(MediaType.audio.name).not() &
                messages.openedAt.isNull() &
                messages.groupId.equals(groupId) &
                messages.mediaId.isNotNull() &
                messages.senderId.isNotNull() &
                messages.type.equals(MessageType.media.name),
          )
          ..orderBy([OrderingTerm.asc(messages.createdAt)]);
    return query.map((row) => row.readTable(messages)).watch();
  }

  Stream<List<MediaFile>> watchUnopenedMediaFiles() {
    final query =
        select(messages).join([
          leftOuterJoin(
            mediaFiles,
            mediaFiles.mediaId.equalsExp(messages.mediaId),
          ),
        ])..where(
          messages.openedAt.isNull() &
              messages.mediaId.isNotNull() &
              messages.type.equals(MessageType.media.name) &
              mediaFiles.downloadState.equals(DownloadState.ready.name) &
              (mediaFiles.type.equals(MediaType.image.name) |
                  mediaFiles.type.equals(MediaType.gif.name)),
        );
    return query.map((row) => row.readTable(mediaFiles)).watch();
  }

  Future<Stream<Message?>> watchLastMessage(String groupId) async {
    final group = await twonlyDB.groupsDao.getGroup(groupId);
    final deletionTime = clock.now().subtract(
      Duration(
        milliseconds: group!.deleteMessagesAfterMilliseconds,
      ),
    );
    final query =
        select(messages).join([
            leftOuterJoin(
              mediaFiles,
              mediaFiles.mediaId.equalsExp(messages.mediaId),
            ),
          ])
          ..where(
            messages.groupId.equals(groupId) &
                // messages in groups will only be removed in case all members have received it...
                // so ensuring that this message is not shown in the messages anymore
                (messages.openedAt.isBiggerThanValue(deletionTime) |
                    messages.openedAt.isNull() |
                    messages.mediaStored.equals(true)) &
                (mediaFiles.downloadState
                        .equals(DownloadState.reuploadRequested.name)
                        .not() |
                    mediaFiles.downloadState.isNull()),
          )
          ..orderBy([OrderingTerm.desc(messages.createdAt)])
          ..limit(1);

    return query.map((row) => row.readTable(messages)).watchSingleOrNull();
  }

  Future<Stream<List<Message>>> watchByGroupId(String groupId) async {
    final group = await twonlyDB.groupsDao.getGroup(groupId);
    final deletionTime = clock.now().subtract(
      Duration(
        milliseconds: group!.deleteMessagesAfterMilliseconds,
      ),
    );
    final query =
        select(messages).join([
            leftOuterJoin(
              mediaFiles,
              mediaFiles.mediaId.equalsExp(messages.mediaId),
            ),
          ])
          ..where(
            messages.groupId.equals(groupId) &
                (messages.openedAt.isBiggerThanValue(deletionTime) |
                    messages.openedAt.isNull() |
                    messages.mediaStored.equals(true)) &
                (messages.isDeletedFromSender.equals(true) |
                    (messages.type.equals(MessageType.text.name).not() &
                        messages.type.equals(MessageType.media.name).not()) |
                    (messages.type.equals(MessageType.text.name) &
                        messages.content.isNotNull()) |
                    (messages.type.equals(MessageType.media.name) &
                        messages.mediaId.isNotNull() &
                        (mediaFiles.downloadState.isNull() |
                            mediaFiles.downloadState
                                .equals(DownloadState.reuploadRequested.name)
                                .not()))),
          )
          ..orderBy([OrderingTerm.asc(messages.createdAt)]);

    return query.map((row) => row.readTable(messages)).watch();
  }

  Stream<List<(GroupMember, Contact)>> watchMembersByGroupId(String groupId) {
    final query = (select(groupMembers).join([
      leftOuterJoin(
        contacts,
        contacts.userId.equalsExp(groupMembers.contactId),
      ),
    ])..where(groupMembers.groupId.equals(groupId)));
    return query
        .map((row) => (row.readTable(groupMembers), row.readTable(contacts)))
        .watch();
  }

  Stream<List<MessageAction>> watchMessageActionChanges(String messageId) {
    return (select(
      messageActions,
    )..where((t) => t.messageId.equals(messageId))).watch();
  }

  Stream<Message?> watchMessageById(String messageId) {
    return (select(
      messages,
    )..where((t) => t.messageId.equals(messageId))).watchSingleOrNull();
  }

  Future<void> purgeMessageTable() async {
    final allGroups = await select(groups).get();

    final groupedByTime = <int, List<String>>{};
    for (final g in allGroups) {
      groupedByTime
          .putIfAbsent(g.deleteMessagesAfterMilliseconds, () => [])
          .add(g.groupId);
    }

    for (final entry in groupedByTime.entries) {
      final deletionTime = clock.now().subtract(
        Duration(milliseconds: entry.key),
      );
      final groupIds = entry.value;

      final deletedCount =
          await (delete(messages)..where(
                (m) =>
                    m.groupId.isIn(groupIds) &
                    ((m.mediaStored.equals(true) &
                            m.isDeletedFromSender.equals(true)) |
                        m.mediaStored.equals(false)) &
                    // Only remove the message when ALL members have seen it. Otherwise the receipt will also be deleted which could cause issues in case a member opens the image later..
                    ((m.openedByAll.isNotNull() &
                            m.openedByAll.isSmallerThanValue(deletionTime)) |
                        (m.isDeletedFromSender.equals(true) &
                            m.createdAt.isSmallerThanValue(deletionTime))),
              ))
              .go();

      if (deletedCount > 0) {
        Log.info(
          'Deleted $deletedCount messages for groups $groupIds due to retention policy.',
        );
      }
    }
  }

  Future<void> handleMessageDeletion(
    int? contactId,
    String messageId,
    DateTime timestamp,
  ) async {
    final msg = await getMessageById(messageId).getSingleOrNull();
    if (msg == null || msg.senderId != contactId) {
      Log.error('Message does not exists or contact is not owner.');
      return;
    }
    if (msg.mediaId != null && contactId != null) {
      final otherMessagesWithSameMedia =
          await (select(messages)..where(
                (t) =>
                    t.mediaId.equals(msg.mediaId!) &
                    t.messageId.equals(messageId).not(),
              ))
              .get();

      if (otherMessagesWithSameMedia.isEmpty) {
        await (delete(
          mediaFiles,
        )..where((t) => t.mediaId.equals(msg.mediaId!))).go();

        final mediaService = await MediaFileService.fromMediaId(msg.mediaId!);
        if (mediaService != null) {
          mediaService.fullMediaRemoval();
        }
      } else {
        Log.info(
          'Media ${msg.mediaId} is still used by ${otherMessagesWithSameMedia.length} other messages. Skipping physical deletion.',
        );
      }
    }
    await (delete(
      messageHistories,
    )..where((t) => t.messageId.equals(messageId))).go();

    await twonlyDB.receiptsDao.deleteReceiptsByMessageId(messageId);

    await (update(messages)..where(
          (t) => t.messageId.equals(messageId),
        ))
        .write(
          const MessagesCompanion(
            isDeletedFromSender: Value(true),
            content: Value(null),
            mediaId: Value(null),
          ),
        );
  }

  Future<void> handleTextEdit(
    int? contactId,
    String messageId,
    String text,
    DateTime timestamp,
  ) async {
    await transaction(() async {
      final msg = await getMessageById(messageId).getSingleOrNull();
      if (msg == null || msg.content == null || msg.senderId != contactId) {
        return;
      }
      await into(messageHistories).insert(
        MessageHistoriesCompanion(
          messageId: Value(messageId),
          content: Value(msg.content),
          createdAt: Value(timestamp),
        ),
      );
      await (update(messages)..where(
            (t) => t.messageId.equals(messageId),
          ))
          .write(
            MessagesCompanion(
              content: Value(text),
              modifiedAt: Value(timestamp),
            ),
          );
    });
  }

  Future<void> handleMessagesOpened(
    Value<int> contactId,
    List<String> messageIds,
    DateTime timestamp,
  ) async {
    if (contactId.present) {
      final contactExists = await twonlyDB.contactsDao.getContactById(
        contactId.value,
      );
      if (contactExists == null) {
        Log.info(
          'handleMessagesOpened: Contact ${contactId.value} does not exist in database, ignoring messages opened action.',
        );
        return;
      }
    }
    for (final messageId in messageIds) {
      try {
        await transaction(() async {
          final msg = await getMessageById(messageId).getSingleOrNull();
          if (msg == null) {
            Log.info(
              'handleMessagesOpened: Message $messageId does not exist in database, skipping.',
            );
            return;
          }
          var ts = timestamp;
          if (ts.isBefore(msg.createdAt)) {
            Log.warn(
              'Receiver clock skew detected for message $messageId. '
              'Action timestamp $ts is before message creation ${msg.createdAt}. '
              'Clamping to creation time.',
            );
            ts = msg.createdAt;
          }

          await into(messageActions).insertOnConflictUpdate(
            MessageActionsCompanion(
              messageId: Value(messageId),
              contactId: contactId,
              type: const Value(MessageActionType.openedAt),
              actionAt: Value(ts),
            ),
          );

          final isOpenedByAll = await haveAllMembers(
            messageId,
            MessageActionType.openedAt,
          );
          await (update(
            messages,
          )..where((tbl) => tbl.messageId.equals(messageId))).write(
            MessagesCompanion(
              openedAt: Value(ts),
              openedByAll: Value(isOpenedByAll ? ts : null),
            ),
          );
        });

        // Read-back verification: confirm the write was persisted.
        final verified = await getMessageById(messageId).getSingleOrNull();
        if (verified != null && verified.openedAt == null) {
          Log.warn(
            'handleMessagesOpened read-back failed for $messageId, retrying',
          );
          await (update(
            messages,
          )..where((tbl) => tbl.messageId.equals(messageId))).write(
            MessagesCompanion(
              openedAt: Value(timestamp),
            ),
          );
        }

        Log.info(
          'handleMessagesOpened completed for message $messageId',
        );
      } catch (e) {
        Log.warn('handleMessagesOpened failed for $messageId: $e');
        Log.error(
          'handleMessagesOpened failed for: $e',
          onlyIfSentryEnabled: true,
        );
      }
    }
  }

  Future<void> handleMessageAckByServer(
    int contactId,
    String messageId,
    DateTime timestamp,
  ) async {
    final contactExists = await twonlyDB.contactsDao.getContactById(contactId);
    if (contactExists == null) {
      Log.info(
        'handleMessageAckByServer: Contact $contactId does not exist in database, ignoring message ack.',
      );
      return;
    }
    await transaction(() async {
      final msg = await getMessageById(messageId).getSingleOrNull();
      if (msg == null) {
        Log.info(
          'handleMessageAckByServer: Message $messageId does not exist in database, skipping.',
        );
        return;
      }
      await into(messageActions).insertOnConflictUpdate(
        MessageActionsCompanion(
          messageId: Value(messageId),
          contactId: Value(contactId),
          type: const Value(MessageActionType.ackByServerAt),
          actionAt: Value(timestamp),
        ),
      );
      await twonlyDB.messagesDao.updateMessageId(
        messageId,
        MessagesCompanion(ackByServer: Value(timestamp)),
      );
    });
  }

  Future<bool> haveAllMembers(
    String messageId,
    MessageActionType action,
  ) async {
    try {
      final message = await twonlyDB.messagesDao
          .getMessageById(messageId)
          .getSingleOrNull();
      if (message == null) return true;
      final members = await twonlyDB.groupsDao.getGroupNonLeftMembers(
        message.groupId,
      );

      final actions =
          await (select(messageActions)..where(
                (t) =>
                    t.type.equals(action.name) & t.messageId.equals(messageId),
              ))
              .get();

      return members.length == actions.length;
    } catch (e) {
      Log.error(e);
      return true;
    }
  }

  Future<void> updateMessageId(
    String messageId,
    MessagesCompanion updatedValues,
  ) async {
    final count = await (update(
      messages,
    )..where((c) => c.messageId.equals(messageId))).write(updatedValues);
    Log.info('Updated $count message(s) with messageId $messageId');
  }

  Future<void> updateMessagesByMediaId(
    String mediaId,
    MessagesCompanion updatedValues,
  ) async {
    final count = await (update(
      messages,
    )..where((c) => c.mediaId.equals(mediaId))).write(updatedValues);
    Log.info('Updated $count message(s) with mediaId $mediaId');
  }

  Future<Message?> insertMessage(MessagesCompanion message) async {
    try {
      var insertMessage = message;

      if (message.messageId == const Value.absent()) {
        insertMessage = message.copyWith(
          messageId: Value(uuid.v7()),
        );
      }

      await into(messages).insertOnConflictUpdate(insertMessage);

      final msgTime = insertMessage.createdAt.present
          ? insertMessage.createdAt.value
          : clock.now();

      await twonlyDB.groupsDao.updateGroup(
        message.groupId.value,
        const GroupsCompanion(
          archived: Value(false),
          deletedContent: Value(false),
        ),
      );

      await twonlyDB.groupsDao.increaseLastMessageExchange(
        message.groupId.value,
        msgTime,
      );

      if (message.senderId.present && message.senderId.value != null) {
        await twonlyDB.groupsDao.increaseMemberLastMessage(
          message.groupId.value,
          message.senderId.value!,
          msgTime,
        );
      }

      final messageId = insertMessage.messageId.value;

      return await (select(
        messages,
      )..where((t) => t.messageId.equals(messageId))).getSingle();
    } catch (e) {
      Log.error('Could not insert message: $e');
      return null;
    }
  }

  Future<MessageAction?> getLastMessageAction(String messageId) async {
    return (((select(messageActions)..where(
              (t) => t.messageId.equals(messageId),
            ))
            ..orderBy([(t) => OrderingTerm.desc(t.actionAt)]))
          ..limit(1))
        .getSingleOrNull();
  }

  Stream<MessageAction?> watchLastMessageAction(String messageId) {
    return (((select(messageActions)..where(
              (t) => t.messageId.equals(messageId),
            ))
            ..orderBy([(t) => OrderingTerm.desc(t.actionAt)]))
          ..limit(1))
        .watchSingleOrNull();
  }

  Future<void> deleteMessagesById(String messageId) {
    return (delete(messages)..where((t) => t.messageId.equals(messageId))).go();
  }

  Future<void> deleteMessagesByGroupId(String groupId) {
    return (delete(messages)..where((t) => t.groupId.equals(groupId))).go();
  }

  SingleOrNullSelectable<Message> getMessageById(String messageId) {
    return select(messages)..where((t) => t.messageId.equals(messageId));
  }

  Future<List<Message>> getMessagesByMediaId(String mediaId) async {
    return (select(messages)..where((t) => t.mediaId.equals(mediaId))).get();
  }

  Future<List<Message>> getMessagesByMediaIds(List<String> mediaIds) async {
    return (select(messages)..where((t) => t.mediaId.isIn(mediaIds))).get();
  }

  Stream<List<(MessageAction, Contact)>> watchMessageActions(String messageId) {
    final query = (select(messageActions).join([
      leftOuterJoin(
        contacts,
        contacts.userId.equalsExp(messageActions.contactId),
      ),
    ])..where(messageActions.messageId.equals(messageId)));
    return query
        .map((row) => (row.readTable(messageActions), row.readTable(contacts)))
        .watch();
  }

  Stream<List<MessageHistory>> watchMessageHistory(String messageId) {
    return (select(messageHistories)
          ..where((t) => t.messageId.equals(messageId))
          ..orderBy([(t) => OrderingTerm.desc(t.createdAt)]))
        .watch();
  }
}
