import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Types } from 'mongoose';
import { ReactionType } from '../../common/enums/reaction-type.enum';
import {
  assertAllowedMediaFile,
  AUDIO_EXTENSIONS,
  getFileExtension,
  IMAGE_EXTENSIONS,
  isAllowedAudioFile,
  isAllowedImageFile,
  isAllowedVideoFile,
  VIDEO_EXTENSIONS,
} from '../../common/media/allowed-media';
import { decodeOffsetCursor, encodeOffsetCursor } from '../../common/utils/cursor.util';
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
import { ManagedStorageService } from '../../infrastructure/storage/managed-storage.service';
import { NotificationsService } from '../notifications/notifications.service';
import { UsersRepository } from '../users/users.repository';
import { CreateConversationDto } from './dto/create-conversation.dto';
import { MessageQueryDto } from './dto/message-query.dto';
import { SendMessageDto } from './dto/send-message.dto';
import { ChatRealtimeService } from './chat-realtime.service';
import { ChatRepository } from './chat.repository';

@Injectable()
export class ChatService {
  private readonly logger = new Logger(ChatService.name);

  constructor(
    private readonly chatRepository: ChatRepository,
    private readonly usersRepository: UsersRepository,
    private readonly notificationsService: NotificationsService,
    private readonly storageService: ManagedStorageService,
    private readonly chatRealtimeService: ChatRealtimeService,
  ) {}

  async createConversation(currentUserId: string, dto: CreateConversationDto) {
    const uniqueParticipantIds = Array.from(new Set([currentUserId, ...dto.participantIds]));
    if (uniqueParticipantIds.length < 2) {
      throw new BadRequestException('Conversation must include at least 2 participants');
    }

    for (const participantId of uniqueParticipantIds) {
      if (!Types.ObjectId.isValid(participantId)) {
        throw new BadRequestException('Invalid participant id');
      }
    }

    const users = await Promise.all(uniqueParticipantIds.map((id) => this.usersRepository.findById(id)));
    if (users.some((u) => !u || u.isDisabled)) {
      throw new BadRequestException('One or more participants are invalid or disabled');
    }

    const isGroup = dto.isGroup ?? uniqueParticipantIds.length > 2;
    if (!isGroup && uniqueParticipantIds.length !== 2) {
      throw new BadRequestException('Direct conversation must contain exactly 2 participants');
    }

    if (!isGroup) {
      const otherId = uniqueParticipantIds.find((id) => id !== currentUserId) as string;
      const block = await this.chatRepository.findAnyBlockBetween(currentUserId, otherId);
      if (block) {
        throw new ForbiddenException('You cannot start chat with this user');
      }
      const existing = await this.chatRepository.findDirectConversation(currentUserId, otherId);
      if (existing) {
        return existing;
      }
    }

    return this.chatRepository.createConversation({
      participantIds: uniqueParticipantIds,
      isGroup,
      title: dto.title,
      createdBy: currentUserId,
    });
  }

  async getMyConversations(currentUserId: string, query: MessageQueryDto) {
    const page = query.page ?? 1;
    const limit = query.limit ?? 20;
    const cursorOffset = decodeOffsetCursor(query.cursor);
    const skip = cursorOffset ?? (page - 1) * limit;
    const direction = resolveMongoSortDirection(query.sortOrder);

    const [items, total] = await Promise.all([
      this.chatRepository.findConversationsForUser(currentUserId, skip, limit, {
        lastMessageAt: direction,
        updatedAt: direction,
      }),
      this.chatRepository.countConversationsForUser(currentUserId),
    ]);

    const mappedItems = items.map((conversation) => {
      const conversationObject = conversation.toObject();
      const unreadMap = this.normalizeUnreadCountByUser(conversationObject.unreadCountByUser);
      return {
        ...conversationObject,
        unreadCountByUser: unreadMap,
        unreadCount: unreadMap[currentUserId] ?? 0,
        lastMessageAt: conversation.lastMessageAt ?? null,
      };
    });
    const nextOffset = skip + mappedItems.length;
    const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;

    return buildPaginatedResponse(mappedItems, {
      page,
      limit,
      total,
      offset: skip,
      currentCursor: query.cursor ?? null,
      nextCursor,
      mode: 'cursor',
    });
  }

  async getMessages(currentUserId: string, conversationId: string, query: MessageQueryDto) {
    if (!conversationId?.trim()) {
      throw new BadRequestException('conversationId is required');
    }

    const conversation = await this.assertConversationMember(currentUserId, conversationId);
    const page = query.page ?? 1;
    const limit = query.limit ?? 20;
    const cursorOffset = decodeOffsetCursor(query.cursor);
    const skip = cursorOffset ?? (page - 1) * limit;
    const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;

    const [items, total] = await Promise.all([
      this.chatRepository.findMessages(conversation.id, currentUserId, skip, limit, sort),
      this.chatRepository.countMessages(conversation.id, currentUserId),
    ]);

    await this.chatRepository.clearConversationUnreadForUser(conversation.id, currentUserId);
    const nextOffset = skip + items.length;
    const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;

    return buildPaginatedResponse(items, {
      page,
      limit,
      total,
      offset: skip,
      currentCursor: query.cursor ?? null,
      nextCursor,
      mode: 'cursor',
    });
  }

  async hideConversationForMe(currentUserId: string, conversationId: string) {
    const conversation = await this.assertConversationMember(currentUserId, conversationId);
    const hiddenAt = new Date();
    const updated = await this.chatRepository.hideConversationForUser(
      conversation.id,
      currentUserId,
      hiddenAt,
    );
    if (!updated) {
      throw new NotFoundException('Conversation not found');
    }

    return {
      success: true,
      conversationId: conversation.id,
      deletedForMe: true,
    };
  }

  async sendMessage(currentUserId: string, dto: SendMessageDto) {
    const conversation = await this.assertConversationMember(currentUserId, dto.conversationId);
    await this.assertNoChatBlockInConversation(currentUserId, conversation.participantIds.map((id) => id.toString()));
    const messageType = dto.messageType ?? 'text';
    const content = dto.content?.trim() ?? '';
    const mediaUrl = dto.mediaUrl?.trim() ?? '';

    if (messageType === 'text' && !content) {
      throw new BadRequestException('Text message content is required');
    }

    if (messageType !== 'text' && !mediaUrl) {
      throw new BadRequestException('mediaUrl is required for non-text messages');
    }

    const replyToMessageId = await this.resolveReplyToMessageId(dto.replyToMessageId, conversation.id);
    const message = await this.chatRepository.createMessage({
      conversationId: conversation.id,
      senderId: currentUserId,
      content,
      messageType,
      mediaUrl,
      replyToMessageId,
    });

    const preview = messageType === 'text' ? content : `${messageType} message`;
    await this.chatRepository.updateConversationAfterNewMessage(
      conversation.id,
      message.id,
      currentUserId,
      preview,
    );
    this.chatRealtimeService.emitNewMessage(message.conversationId.toString(), message);
    await this.dispatchMessageNotifications(
      currentUserId,
      conversation.participantIds.map((id) => id.toString()),
      conversation.id,
      preview,
      message.id,
    );

    return message;
  }

  async sendMessageWithUpload(
    currentUserId: string,
    dto: SendMessageDto,
    file?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string },
  ) {
    if (!file) {
      throw new BadRequestException('mediaFile is required');
    }

    const mediaType = this.resolveUploadedMessageType(file);
    const mediaUrl = await this.storageService.saveFile({
      folderSegments: ['chat', 'media'],
      extension: this.resolveMediaExtension(mediaType, file),
      buffer: file.buffer,
      contentType: file.mimetype,
      fileNamePrefix: 'message',
    });

    try {
      return await this.sendMessage(currentUserId, {
        ...dto,
        messageType: mediaType,
        mediaUrl,
      });
    } catch (error) {
      await this.storageService.deleteFile(mediaUrl);
      throw error;
    }
  }

  async markMessageSeen(currentUserId: string, messageId: string, conversationId?: string) {
    if (!Types.ObjectId.isValid(messageId)) {
      throw new BadRequestException('Invalid message id');
    }
    if (conversationId && !Types.ObjectId.isValid(conversationId)) {
      throw new BadRequestException('Invalid conversation id');
    }

    const message = await this.chatRepository.findMessageById(messageId);
    if (!message) {
      throw new NotFoundException('Message not found');
    }
    if (conversationId && message.conversationId.toString() !== conversationId) {
      throw new BadRequestException('Message must belong to the conversation');
    }

    await this.assertConversationMember(currentUserId, message.conversationId.toString());
    await this.chatRepository.markMessageSeen(message.id, currentUserId);
    await this.chatRepository.clearConversationUnreadForUser(message.conversationId.toString(), currentUserId);
    const seenAt = new Date();

    return {
      success: true,
      conversationId: message.conversationId.toString(),
      messageId: message.id,
      userId: currentUserId,
      seenAt: seenAt.toISOString(),
    };
  }

  async markMessageDelivered(currentUserId: string, conversationId: string, messageId: string) {
    const conversation = await this.assertConversationMember(currentUserId, conversationId);
    if (!Types.ObjectId.isValid(messageId)) {
      throw new BadRequestException('Invalid message id');
    }

    const message = await this.chatRepository.findMessageById(messageId);
    if (!message || message.conversationId.toString() !== conversation.id) {
      throw new BadRequestException('Message must belong to the conversation');
    }

    if (message.senderId.toString() === currentUserId) {
      throw new BadRequestException('Sender cannot mark own message delivered');
    }

    const deliveredAt = new Date();
    await this.chatRepository.markMessageDelivered(message.id, currentUserId, deliveredAt);

    return {
      conversationId: conversation.id,
      messageId: message.id,
      userId: currentUserId,
      deliveredAt: deliveredAt.toISOString(),
    };
  }

  async unsendMessage(currentUserId: string, messageId: string) {
    const message = await this.chatRepository.findMessageById(messageId);
    if (!message) {
      throw new NotFoundException('Message not found');
    }
    if (message.senderId.toString() !== currentUserId) {
      throw new ForbiddenException('You can only unsend your own messages');
    }

    const updated = await this.chatRepository.unsendMessage(messageId, currentUserId);
    if (!updated) {
      throw new NotFoundException('Message not found');
    }
    return updated;
  }

  async reactToMessage(currentUserId: string, messageId: string, reactionType: ReactionType) {
    const message = await this.chatRepository.findMessageById(messageId);
    if (!message) {
      throw new NotFoundException('Message not found');
    }
    await this.assertConversationMember(currentUserId, message.conversationId.toString());
    const updated = await this.chatRepository.setMessageReaction(messageId, currentUserId, reactionType);
    if (!updated) {
      throw new NotFoundException('Message not found');
    }
    return updated;
  }

  async deleteMessageForMe(currentUserId: string, messageId: string) {
    const message = await this.chatRepository.findMessageById(messageId);
    if (!message) {
      throw new NotFoundException('Message not found');
    }
    await this.assertConversationMember(currentUserId, message.conversationId.toString());
    await this.chatRepository.deleteMessageForUser(messageId, currentUserId);
    return { success: true };
  }

  async blockUser(currentUserId: string, targetUserId: string) {
    if (!Types.ObjectId.isValid(targetUserId)) {
      throw new BadRequestException('Invalid target user id');
    }
    if (currentUserId === targetUserId) {
      throw new BadRequestException('You cannot block yourself');
    }

    const target = await this.usersRepository.findById(targetUserId);
    if (!target) {
      throw new NotFoundException('Target user not found');
    }

    await this.chatRepository.createBlock(currentUserId, targetUserId);
    return { blocked: true, targetUserId };
  }

  async unblockUser(currentUserId: string, targetUserId: string) {
    if (!Types.ObjectId.isValid(targetUserId)) {
      throw new BadRequestException('Invalid target user id');
    }
    await this.chatRepository.removeBlock(currentUserId, targetUserId);
    return { blocked: false, targetUserId };
  }

  async getBlockStatus(currentUserId: string, targetUserId: string) {
    if (!Types.ObjectId.isValid(targetUserId)) {
      throw new BadRequestException('Invalid target user id');
    }

    const iBlocked = !!(await this.chatRepository.findBlock(currentUserId, targetUserId));
    const blockedMe = !!(await this.chatRepository.findBlock(targetUserId, currentUserId));

    return { targetUserId, iBlocked, blockedMe };
  }

  async getMyBlockedUsers(currentUserId: string) {
    const items = await this.chatRepository.findBlocksByBlocker(currentUserId);
    return { items };
  }

  async assertConversationMember(userId: string, conversationId: string) {
    if (!Types.ObjectId.isValid(conversationId)) {
      throw new BadRequestException('Invalid conversation id');
    }

    const conversation = await this.chatRepository.findConversationById(conversationId);
    if (!conversation) {
      throw new NotFoundException('Conversation not found');
    }

    const isMember = conversation.participantIds.some((id) => id.toString() === userId);
    if (!isMember) {
      throw new ForbiddenException('You are not a member of this conversation');
    }

    return conversation;
  }

  private async assertNoChatBlockInConversation(currentUserId: string, participantIds: string[]) {
    for (const participantId of participantIds) {
      if (participantId === currentUserId) {
        continue;
      }
      const block = await this.chatRepository.findAnyBlockBetween(currentUserId, participantId);
      if (block) {
        throw new ForbiddenException('Cannot send message because one of participants is blocked');
      }
    }
  }

  private normalizeUnreadCountByUser(value: unknown): Record<string, number> {
    if (!value) {
      return {};
    }

    if (value instanceof Map) {
      return Object.fromEntries(
        Array.from(value.entries()).map(([key, count]) => [key.toString(), Number(count) || 0]),
      );
    }

    if (typeof value === 'object') {
      return Object.fromEntries(
        Object.entries(value as Record<string, unknown>)
          .filter(([key]) => !key.startsWith('$__') && key !== '$isMongooseMap')
          .map(([key, count]) => [key, Number(count) || 0]),
      );
    }

    return {};
  }

  private async resolveReplyToMessageId(replyToMessageId: string | undefined, conversationId: string) {
    if (!replyToMessageId) {
      return null;
    }
    if (!Types.ObjectId.isValid(replyToMessageId)) {
      throw new BadRequestException('Invalid replyToMessageId');
    }
    const replyMessage = await this.chatRepository.findMessageById(replyToMessageId);
    if (!replyMessage || replyMessage.conversationId.toString() !== conversationId) {
      throw new BadRequestException('Reply message must belong to the same conversation');
    }
    return replyMessage.id;
  }

  private resolveUploadedMessageType(file: { mimetype?: string; originalname?: string }) {
    const mimetype = file.mimetype?.toLowerCase();
    if (mimetype?.startsWith('image/') && isAllowedImageFile(file)) {
      return 'image' as const;
    }
    if (mimetype?.startsWith('video/') && isAllowedVideoFile(file)) {
      return 'video' as const;
    }
    if (mimetype?.startsWith('audio/') && isAllowedAudioFile(file)) {
      return 'audio' as const;
    }
    const extension = getFileExtension(file.originalname);
    if (IMAGE_EXTENSIONS.includes(extension as (typeof IMAGE_EXTENSIONS)[number])) {
      return 'image' as const;
    }
    if (VIDEO_EXTENSIONS.includes(extension as (typeof VIDEO_EXTENSIONS)[number])) {
      return 'video' as const;
    }
    if (AUDIO_EXTENSIONS.includes(extension as (typeof AUDIO_EXTENSIONS)[number])) {
      return 'audio' as const;
    }
    throw new BadRequestException('mediaFile must be image, video, or audio');
  }

  private resolveMediaExtension(
    mediaType: 'image' | 'video' | 'audio',
    file: { mimetype?: string; originalname?: string },
  ): string {
    return assertAllowedMediaFile(file, mediaType, {
      invalidMessage: 'mediaFile must be image, video, or audio',
    });
  }

  private async dispatchMessageNotifications(
    actorId: string,
    participantIds: string[],
    conversationId: string,
    previewText: string,
    messageId: string,
  ): Promise<void> {
    for (const recipientId of participantIds) {
      if (recipientId === actorId) {
        continue;
      }

      try {
        await this.notificationsService.createMessageNotification(
          actorId,
          recipientId,
          conversationId,
          previewText.slice(0, 160),
          messageId,
        );
      } catch (error) {
        this.logger.warn(
          `Message notification failed for actor=${actorId} recipient=${recipientId}: ${
            error instanceof Error ? error.message : 'unknown error'
          }`,
        );
      }
    }
  }
}
