import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Types } from 'mongoose';
import { PaginationQueryDto } from '../../common/dto/pagination-query.dto';
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
import { FeedVersionService } from '../../infrastructure/cache/feed-version.service';
import { BlocksRepository } from '../blocks/blocks.repository';
import { OutboxService } from '../outbox/outbox.service';
import { UsersRepository } from '../users/users.repository';
import { UserDocument } from '../users/schemas/user.schema';
import { ToggleFollowDto } from './dto/toggle-follow.dto';
import { FollowsRepository } from './follows.repository';

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

  constructor(
    private readonly followsRepository: FollowsRepository,
    private readonly usersRepository: UsersRepository,
    private readonly outboxService: OutboxService,
    private readonly feedVersionService: FeedVersionService,
    private readonly blocksRepository: BlocksRepository,
  ) {}

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

    const targetUser = await this.findActiveTargetUser(targetUserId);

    const existing = await this.followsRepository.findOne(currentUserId, targetUserId);

    if (existing) {
      await this.followsRepository.deleteById(existing.id);
      await this.syncFollowCounts(currentUserId, targetUserId);
      await this.feedVersionService.bumpUserVersion(currentUserId);
      return { following: false };
    }

    const block = await this.blocksRepository.findAnyBetween(currentUserId, targetUserId);
    if (block) {
      throw new BadRequestException('You cannot follow this user');
    }

    if (targetUser.isPrivate) {
      const request = await this.followsRepository.upsertPendingRequest(currentUserId, targetUserId);
      return { following: false, requested: true, requestId: request.id };
    }

    let followId: string | null = null;
    try {
      const follow = await this.followsRepository.create(currentUserId, targetUserId);
      followId = follow.id;
    } catch (error) {
      if (!this.followsRepository.isDuplicateKeyError(error)) {
        throw error;
      }
    }
    await this.syncFollowCounts(currentUserId, targetUserId);
    await this.feedVersionService.bumpUserVersion(currentUserId);

    if (followId) {
      await this.enqueueFollowNotification(currentUserId, targetUserId, followId);
    }

    return { following: true };
  }

  async followUser(currentUserId: string, targetUserId: string) {
    await this.assertCanFollowTarget(currentUserId, targetUserId);
    const targetUser = await this.findActiveTargetUser(targetUserId);

    const existing = await this.followsRepository.findOne(currentUserId, targetUserId);
    if (existing) {
      const counts = await this.syncFollowCounts(currentUserId, targetUserId);
      return this.buildFollowActionResponse('Already following user', true, targetUserId, counts);
    }

    if (targetUser.isPrivate) {
      const request = await this.followsRepository.upsertPendingRequest(currentUserId, targetUserId);
      const counts = await this.getFollowCounts(currentUserId, targetUserId);
      return {
        ...this.buildFollowActionResponse('Follow request sent', false, targetUserId, counts),
        requested: true,
        requestId: request.id,
      };
    }

    let followId: string | null = null;
    try {
      const follow = await this.followsRepository.create(currentUserId, targetUserId);
      followId = follow.id;
    } catch (error) {
      if (!this.followsRepository.isDuplicateKeyError(error)) {
        throw error;
      }
    }

    const counts = await this.syncFollowCounts(currentUserId, targetUserId);
    await this.feedVersionService.bumpUserVersion(currentUserId);

    if (followId) {
      await this.enqueueFollowNotification(currentUserId, targetUserId, followId);
    }

    return this.buildFollowActionResponse('User followed successfully', true, targetUserId, counts);
  }

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

    const existing = await this.followsRepository.findOne(currentUserId, targetUserId);
    if (existing) {
      await this.followsRepository.deleteById(existing.id);
      await this.feedVersionService.bumpUserVersion(currentUserId);
    }

    const counts = await this.syncFollowCounts(currentUserId, targetUserId);
    return this.buildFollowActionResponse('User unfollowed successfully', false, targetUserId, counts);
  }

  async removeFollower(currentUserId: string, followerId: string) {
    if (!Types.ObjectId.isValid(followerId)) {
      throw new BadRequestException('Invalid follower user id');
    }
    if (currentUserId === followerId) {
      throw new BadRequestException('You cannot remove yourself as a follower');
    }

    await Promise.all([
      this.followsRepository.deleteRelationship(followerId, currentUserId),
      this.followsRepository.deletePendingRequest(followerId, currentUserId),
    ]);
    const counts = await this.syncFollowCounts(followerId, currentUserId);
    await this.feedVersionService.bumpUserVersion(followerId);

    return {
      message: 'Follower removed successfully',
      followerId,
      isFollowing: false,
      followersCount: counts.followersCount,
      followingCount: counts.followingCount,
    };
  }

  async getFollowers(userId: string, query: PaginationQueryDto, viewerUserId?: string) {
    await this.assertListTargetExists(userId);
    const page = query.page ?? 1;
    const limit = query.limit ?? 20;
    const skip = (page - 1) * limit;
    const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
    const [followRows, total] = await Promise.all([
      this.followsRepository.findFollowerUserIds(userId, skip, limit, sort),
      this.followsRepository.count({ followingId: userId }),
    ]);
    const items = await this.decorateFollowUsers(
      followRows.map((row) => row.followerId.toString()),
      viewerUserId,
    );

    return buildPaginatedResponse(items, {
      page,
      limit,
      total,
      offset: skip,
    });
  }

  async getFollowing(userId: string, query: PaginationQueryDto, viewerUserId?: string) {
    await this.assertListTargetExists(userId);
    const page = query.page ?? 1;
    const limit = query.limit ?? 20;
    const skip = (page - 1) * limit;
    const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
    const [followRows, total] = await Promise.all([
      this.followsRepository.findFollowingUserIds(userId, skip, limit, sort),
      this.followsRepository.count({ followerId: userId }),
    ]);
    const items = await this.decorateFollowUsers(
      followRows.map((row) => row.followingId.toString()),
      viewerUserId,
    );

    return buildPaginatedResponse(items, {
      page,
      limit,
      total,
      offset: skip,
    });
  }

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

    if (currentUserId === targetUserId) {
      return {
        following: false,
        isFollowing: false,
        isFollowedBy: false,
        isMutual: false,
        targetUserId,
      };
    }

    const [existing, reverseExisting] = await Promise.all([
      this.followsRepository.findOne(currentUserId, targetUserId),
      this.followsRepository.findOne(targetUserId, currentUserId),
    ]);
    const pendingRequest =
      currentUserId === targetUserId
        ? null
        : await this.followsRepository.findPendingRequest(currentUserId, targetUserId);
    return {
      following: !!existing,
      isFollowing: !!existing,
      isFollowedBy: !!reverseExisting,
      isMutual: !!existing && !!reverseExisting,
      requested: !!pendingRequest,
      targetUserId,
    };
  }

  async getPendingRequests(currentUserId: string, query: PaginationQueryDto) {
    const page = query.page ?? 1;
    const limit = query.limit ?? 20;
    const skip = (page - 1) * limit;
    const sort = { createdAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
    const [items, total] = await Promise.all([
      this.followsRepository.findPendingRequestsForTarget(currentUserId, skip, limit, sort),
      this.followsRepository.countPendingRequestsForTarget(currentUserId),
    ]);

    return buildPaginatedResponse(items, { page, limit, total, offset: skip });
  }

  async approveRequest(currentUserId: string, requestId: string) {
    if (!Types.ObjectId.isValid(requestId)) {
      throw new BadRequestException('Invalid follow request id');
    }

    const microservicesEnabled = process.env.NOTIFICATION_SERVICE_ENABLED === 'true'
      || process.env.OUTBOX_DISPATCHER_ENABLED === 'true';
    const transaction = await this.followsRepository.runInTransaction(async (session) => {
      const request = await this.followsRepository.updateRequestStatus(
        requestId,
        currentUserId,
        'approved',
        session,
      );
      if (!request) {
        const existingRequest = await this.followsRepository.findRequestByIdForTarget(
          requestId,
          currentUserId,
          session,
        );
        if (existingRequest?.status === 'approved') {
          return {
            requesterId: existingRequest.requesterId.toString(),
            followCreated: false,
            eventId: null,
          };
        }
        throw new NotFoundException('Follow request not found');
      }

      const requesterId = request.requesterId.toString();
      const existing = await this.followsRepository.findOne(requesterId, currentUserId, session);
      let followCreated = false;
      if (!existing) {
        await this.followsRepository.create(requesterId, currentUserId, session);
        // MongoDB transactions do not support parallel operations on one session.
        await this.usersRepository.incrementFollowingCount(requesterId, 1, session);
        await this.usersRepository.incrementFollowersCount(currentUserId, 1, session);
        followCreated = true;
      }

      const eventId = await this.outboxService.createFollowRequestApprovedEvent(
        currentUserId,
        requesterId,
        requestId,
        session,
      );
      return { requesterId, followCreated, eventId };
    }, microservicesEnabled);

    if (transaction.followCreated) await this.feedVersionService.bumpUserVersion(transaction.requesterId);
    if (!microservicesEnabled && transaction.eventId) {
      try {
        await this.outboxService.enqueuePersistedEvent(transaction.eventId);
      } catch (error) {
        this.logger.warn(`Legacy outbox enqueue failed; event remains pending: ${error instanceof Error ? error.message : 'unknown error'}`);
      }
    }

    const requesterId = transaction.requesterId;

    return { approved: true, following: true, requesterId };
  }

  async rejectRequest(currentUserId: string, requestId: string) {
    if (!Types.ObjectId.isValid(requestId)) {
      throw new BadRequestException('Invalid follow request id');
    }

    const request = await this.followsRepository.updateRequestStatus(requestId, currentUserId, 'rejected');
    if (!request) {
      throw new NotFoundException('Follow request not found');
    }

    return { rejected: true, requesterId: request.requesterId.toString() };
  }

  async getSuggestions(currentUserId: string, query: PaginationQueryDto) {
    const page = query.page ?? 1;
    const limit = query.limit ?? 20;
    const currentUser = await this.usersRepository.findById(currentUserId);
    if (!currentUser) {
      throw new NotFoundException('Current user not found');
    }

    const followingIds = await this.followsRepository.findFollowingIds(currentUserId);
    const excludedIds = new Set<string>([currentUserId, ...followingIds]);

    const candidates = await this.usersRepository.findSuggestionCandidates(
      {
        _id: { $nin: Array.from(excludedIds).map((id) => new Types.ObjectId(id)) },
        isDisabled: false,
      },
      500,
    );

    const ranked = candidates
      .map((candidate) => ({
        user: candidate,
        score: this.calculateSuggestionScore(currentUser, candidate),
      }))
      .sort((a, b) => b.score - a.score || b.user.followersCount - a.user.followersCount);

    if (query.sortOrder === 'asc') {
      ranked.reverse();
    }

    const total = ranked.length;
    const skip = (page - 1) * limit;
    const items = ranked.slice(skip, skip + limit).map((entry) => ({
      user: entry.user,
      score: entry.score,
      reasons: this.buildSuggestionReasons(currentUser, entry.user),
    }));

    return buildPaginatedResponse(items, {
      page,
      limit,
      total,
      offset: skip,
    });
  }

  private calculateSuggestionScore(currentUser: UserDocument, candidate: UserDocument): number {
    const sharedRoles = this.intersectionCount(currentUser.musicRoles, candidate.musicRoles);
    const sharedGenres = this.intersectionCount(currentUser.musicGenres, candidate.musicGenres);
    const sharedInstruments = this.intersectionCount(
      currentUser.favoriteInstruments,
      candidate.favoriteInstruments,
    );
    const sharedMaqamat = this.intersectionCount(currentUser.favoriteMaqamat, candidate.favoriteMaqamat);
    const sameLocation = this.normalize(currentUser.location) === this.normalize(candidate.location);

    let score = 0;
    score += sharedRoles * 15;
    score += sharedGenres * 8;
    score += sharedInstruments * 6;
    score += sharedMaqamat * 6;
    score += sameLocation ? 20 : 0;
    score += candidate.isVerified ? 25 : 0;
    score += Math.min(25, Math.floor(candidate.followersCount / 100));
    score += Math.random();

    return score;
  }

  private buildSuggestionReasons(currentUser: UserDocument, candidate: UserDocument): string[] {
    const reasons: string[] = [];

    if (this.intersectionCount(currentUser.musicRoles, candidate.musicRoles) > 0) {
      reasons.push('shared_music_roles');
    }
    if (this.intersectionCount(currentUser.musicGenres, candidate.musicGenres) > 0) {
      reasons.push('shared_genres');
    }
    if (this.normalize(currentUser.location) === this.normalize(candidate.location)) {
      reasons.push('same_location');
    }
    if (candidate.isVerified) {
      reasons.push('verified_account');
    }
    if (candidate.followersCount > 500) {
      reasons.push('popular_creator');
    }

    return reasons;
  }

  private normalize(value?: string): string {
    return (value ?? '').trim().toLowerCase();
  }

  private intersectionCount(a: string[] = [], b: string[] = []): number {
    if (!a.length || !b.length) {
      return 0;
    }

    const right = new Set(b.map((item) => item.toLowerCase()));
    let count = 0;
    for (const item of a) {
      if (right.has(item.toLowerCase())) {
        count += 1;
      }
    }
    return count;
  }

  private async syncFollowCounts(
    currentUserId: string,
    targetUserId: string,
  ): Promise<{ followingCount: number; followersCount: number }> {
    const [followingCount, followersCount] = await Promise.all([
      this.followsRepository.count({ followerId: currentUserId }),
      this.followsRepository.count({ followingId: targetUserId }),
    ]);

    await Promise.all([
      this.usersRepository.setFollowingCount(currentUserId, followingCount),
      this.usersRepository.setFollowersCount(targetUserId, followersCount),
    ]);

    return { followingCount, followersCount };
  }

  private async getFollowCounts(
    currentUserId: string,
    targetUserId: string,
  ): Promise<{ followingCount: number; followersCount: number }> {
    const [followingCount, followersCount] = await Promise.all([
      this.followsRepository.count({ followerId: currentUserId }),
      this.followsRepository.count({ followingId: targetUserId }),
    ]);

    return { followingCount, followersCount };
  }

  private async assertCanFollowTarget(currentUserId: string, targetUserId: string): Promise<void> {
    if (!Types.ObjectId.isValid(targetUserId)) {
      throw new BadRequestException('Invalid target user id');
    }

    if (currentUserId === targetUserId) {
      throw new BadRequestException('You cannot follow yourself');
    }

    const block = await this.blocksRepository.findAnyBetween(currentUserId, targetUserId);
    if (block) {
      throw new BadRequestException('You cannot follow this user');
    }
  }

  private async findActiveTargetUser(targetUserId: string): Promise<UserDocument> {
    const targetUser = await this.usersRepository.findById(targetUserId);
    if (!targetUser || targetUser.isDisabled) {
      throw new NotFoundException('Target user not found');
    }
    return targetUser;
  }

  private async assertListTargetExists(userId: string): Promise<void> {
    if (!Types.ObjectId.isValid(userId)) {
      throw new BadRequestException('Invalid user id');
    }
    await this.findActiveTargetUser(userId);
  }

  private async enqueueFollowNotification(actorId: string, recipientId: string, followId: string): Promise<void> {
    try {
      await this.outboxService.enqueueFollowNotification(actorId, recipientId, followId);
    } catch (error) {
      this.logger.warn(
        `Follow notification failed for actor=${actorId} recipient=${recipientId}: ${
          error instanceof Error ? error.message : 'unknown error'
        }`,
      );
    }
  }

  private buildFollowActionResponse(
    message: string,
    isFollowing: boolean,
    targetUserId: string,
    counts: { followingCount: number; followersCount: number },
  ) {
    return {
      message,
      following: isFollowing,
      isFollowing,
      targetUserId,
      followersCount: counts.followersCount,
      followingCount: counts.followingCount,
    };
  }

  private async decorateFollowUsers(userIds: string[], viewerUserId?: string) {
    const users = await this.usersRepository.findManyByIds(userIds);
    const usersById = new Map(users.map((user) => [user.id, user]));
    const viewerFollowingIds = viewerUserId ? new Set(await this.followsRepository.findFollowingIds(viewerUserId)) : new Set();

    return userIds
      .map((id) => usersById.get(id))
      .filter((user): user is UserDocument => !!user)
      .map((user) => {
        const object = user.toObject();
        return {
          _id: object._id,
          name: object.name,
          stageName: object.stageName,
          username: object.username,
          avatar: object.avatar,
          isVerified: object.isVerified,
          isDisabled: object.isDisabled,
          followersCount: object.followersCount ?? 0,
          followingCount: object.followingCount ?? 0,
          isFollowing: viewerFollowingIds.has(user.id),
        };
      });
  }
}
