import { ConfigService } from '@nestjs/config';
import {
  BadRequestException,
  ForbiddenException,
  NotFoundException,
  UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import {
  ConnectedSocket,
  MessageBody,
  OnGatewayConnection,
  OnGatewayDisconnect,
  OnGatewayInit,
  SubscribeMessage,
  WebSocketGateway,
  WebSocketServer,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { ChatRealtimeService } from './chat-realtime.service';
import { ChatService } from './chat.service';
import { SendMessageDto } from './dto/send-message.dto';
import { UsersService } from '../users/users.service';

type SocketWithUser = Socket & { data: { userId?: string } };

@WebSocketGateway({
  cors: {
    origin:
      process.env.NODE_ENV === 'production'
        ? (process.env.CORS_ORIGINS ?? '').split(',').map((value) => value.trim()).filter(Boolean)
        : true,
    credentials: true,
  },
  namespace: 'chat',
})
export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
  @WebSocketServer()
  server!: Server;
  // TODO: Move presence counts to Redis when running multiple backend instances.
  private readonly connectionCountsByUser = new Map<string, number>();
  private readonly typingTimestampsBySocket = new Map<string, number>();

  constructor(
    private readonly chatService: ChatService,
    private readonly chatRealtimeService: ChatRealtimeService,
    private readonly jwtService: JwtService,
    private readonly configService: ConfigService,
    private readonly usersService: UsersService,
  ) {}

  afterInit(server: Server) {
    this.chatRealtimeService.bindServer(server);
  }

  async handleConnection(client: SocketWithUser) {
    const token = this.extractToken(client);
    if (!token) {
      client.disconnect(true);
      return;
    }

    try {
      const payload = this.jwtService.verify<{ sub: string; tokenType: string }>(token, {
        secret: this.configService.get<string>('jwt.accessSecret', { infer: true }),
      });
      if (payload.tokenType !== 'access') {
        client.disconnect(true);
        return;
      }
      const user = await this.usersService.findByIdOrFail(payload.sub);
      if (user.isDisabled) {
        client.disconnect(true);
        return;
      }
      client.data.userId = payload.sub;
      const connectionCount = this.incrementUserConnection(payload.sub);
      if (connectionCount === 1) {
        await this.usersService.setPresence(payload.sub, true);
      }
      await client.join(this.userRoom(payload.sub));
      this.server.to(this.userRoom(payload.sub)).emit('presence', {
        userId: payload.sub,
        isOnline: true,
        online: true,
        lastSeenAt: null,
      });
    } catch {
      client.disconnect(true);
    }
  }

  handleDisconnect(client: SocketWithUser) {
    const userId = client.data.userId;
    if (userId) {
      const remainingConnections = this.decrementUserConnection(userId);
      if (remainingConnections === 0) {
        const lastSeenAt = new Date().toISOString();
        void this.usersService.setPresence(userId, false);
        this.server.to(this.userRoom(userId)).emit('presence', {
          userId,
          isOnline: false,
          online: false,
          lastSeenAt,
        });
      }
    }
    this.typingTimestampsBySocket.delete(client.id);
  }

  @SubscribeMessage('join_conversation')
  async joinConversation(
    @ConnectedSocket() client: SocketWithUser,
    @MessageBody() body: { conversationId: string },
  ) {
    const userId = client.data.userId;
    if (!userId) return this.emitSocketError(client, 'join_conversation', 'Unauthorized socket', 'UNAUTHORIZED');

    try {
      const conversation = await this.chatService.assertConversationMember(userId, body?.conversationId);
      await client.join(this.conversationRoom(conversation.id));
      client.emit('joined_conversation', { conversationId: conversation.id });
    } catch (error) {
      this.emitSocketError(client, 'join_conversation', error);
    }
  }

  @SubscribeMessage('leave_conversation')
  async leaveConversation(
    @ConnectedSocket() client: SocketWithUser,
    @MessageBody() body: { conversationId: string },
  ) {
    const userId = client.data.userId;
    if (!userId) return this.emitSocketError(client, 'leave_conversation', 'Unauthorized socket', 'UNAUTHORIZED');

    try {
      const conversation = await this.chatService.assertConversationMember(userId, body?.conversationId);
      await client.leave(this.conversationRoom(conversation.id));
      client.emit('left_conversation', { conversationId: conversation.id });
    } catch (error) {
      this.emitSocketError(client, 'leave_conversation', error);
    }
  }

  @SubscribeMessage('send_message')
  async sendMessage(
    @ConnectedSocket() client: SocketWithUser,
    @MessageBody() dto: SendMessageDto,
  ) {
    const userId = client.data.userId;
    if (!userId) return this.emitSocketError(client, 'send_message', 'Unauthorized socket', 'UNAUTHORIZED');

    try {
      const message = await this.chatService.sendMessage(userId, dto);
      return message;
    } catch (error) {
      this.emitSocketError(client, 'send_message', error);
      return null;
    }
  }

  @SubscribeMessage('typing')
  async typing(
    @ConnectedSocket() client: SocketWithUser,
    @MessageBody() body: { conversationId: string; isTyping: boolean },
  ) {
    const userId = client.data.userId;
    if (!userId) return this.emitSocketError(client, 'typing', 'Unauthorized socket', 'UNAUTHORIZED');

    try {
      const now = Date.now();
      const previous = this.typingTimestampsBySocket.get(client.id) ?? 0;
      if (now - previous < 500) {
        return;
      }
      this.typingTimestampsBySocket.set(client.id, now);

      const conversation = await this.chatService.assertConversationMember(userId, body?.conversationId);
      client.to(this.conversationRoom(conversation.id)).emit('typing', {
        conversationId: conversation.id,
        userId,
        isTyping: !!body?.isTyping,
      });
    } catch (error) {
      this.emitSocketError(client, 'typing', error);
    }
  }

  @SubscribeMessage('mark_seen')
  async markSeen(
    @ConnectedSocket() client: SocketWithUser,
    @MessageBody() body: { messageId: string; conversationId: string },
  ) {
    const userId = client.data.userId;
    if (!userId) return this.emitSocketError(client, 'mark_seen', 'Unauthorized socket', 'UNAUTHORIZED');

    try {
      const seen = await this.chatService.markMessageSeen(userId, body?.messageId, body?.conversationId);
      this.server.to(this.conversationRoom(seen.conversationId)).emit('message_seen', {
        conversationId: seen.conversationId,
        messageId: seen.messageId,
        userId,
        seenAt: seen.seenAt,
      });
    } catch (error) {
      this.emitSocketError(client, 'mark_seen', error);
    }
  }

  @SubscribeMessage('mark_delivered')
  async markDelivered(
    @ConnectedSocket() client: SocketWithUser,
    @MessageBody() body: { messageId: string; conversationId: string },
  ) {
    const userId = client.data.userId;
    if (!userId) return this.emitSocketError(client, 'mark_delivered', 'Unauthorized socket', 'UNAUTHORIZED');

    try {
      const delivered = await this.chatService.markMessageDelivered(
        userId,
        body?.conversationId,
        body?.messageId,
      );
      this.server.to(this.conversationRoom(delivered.conversationId)).emit('message_delivered', delivered);
    } catch (error) {
      this.emitSocketError(client, 'mark_delivered', error);
    }
  }

  private extractToken(client: Socket): string | null {
    const authToken = client.handshake.auth?.token;
    if (typeof authToken === 'string' && authToken.trim()) {
      return authToken.replace(/^Bearer\s+/i, '').trim();
    }

    const headerAuth = client.handshake.headers.authorization;
    if (typeof headerAuth === 'string' && headerAuth.trim()) {
      return headerAuth.replace(/^Bearer\s+/i, '').trim();
    }

    return null;
  }

  private userRoom(userId: string): string {
    return `user:${userId}`;
  }

  private conversationRoom(conversationId: string): string {
    return `conversation:${conversationId}`;
  }

  private incrementUserConnection(userId: string): number {
    const nextCount = (this.connectionCountsByUser.get(userId) ?? 0) + 1;
    this.connectionCountsByUser.set(userId, nextCount);
    return nextCount;
  }

  private decrementUserConnection(userId: string): number {
    const nextCount = Math.max(0, (this.connectionCountsByUser.get(userId) ?? 1) - 1);
    if (nextCount === 0) {
      this.connectionCountsByUser.delete(userId);
      return 0;
    }
    this.connectionCountsByUser.set(userId, nextCount);
    return nextCount;
  }

  private emitSocketError(
    client: SocketWithUser,
    event: string,
    errorOrMessage: unknown,
    explicitCode?: string,
  ): void {
    const message = errorOrMessage instanceof Error ? errorOrMessage.message : String(errorOrMessage);
    client.emit('socket_error', {
      event,
      message,
      code: explicitCode ?? this.resolveErrorCode(errorOrMessage),
    });
  }

  private resolveErrorCode(error: unknown): string {
    if (error instanceof BadRequestException) return 'BAD_REQUEST';
    if (error instanceof ForbiddenException) return 'FORBIDDEN';
    if (error instanceof NotFoundException) return 'NOT_FOUND';
    if (error instanceof UnauthorizedException) return 'UNAUTHORIZED';
    return 'ERROR';
  }
}
