import { BadRequestException, Injectable } from '@nestjs/common';
import { InjectConnection, InjectModel } from '@nestjs/mongoose';
import { Connection, FilterQuery, Model, PipelineStage, Types } from 'mongoose';
import { ModerationStatus } from '../../common/enums/moderation-status.enum';
import { PostVisibility } from '../../common/enums/post-visibility.enum';
import { buildPaginatedResponse, PaginatedResponse } from '../../common/utils/pagination.util';
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
import { BlocksRepository } from '../blocks/blocks.repository';
import { Post, PostDocument } from '../posts/schemas/post.schema';
import { User, UserDocument } from '../users/schemas/user.schema';
import { SearchQueryDto, SearchSuggestionsQueryDto, SearchType } from './dto/search-query.dto';

type SearchUserItem = {
  _id: string;
  name: string;
  stageName: string;
  username: string;
  avatar: string;
  isVerified: boolean;
  isDisabled: boolean;
  followersCount: number;
  followingCount: number;
  isFollowing: boolean;
};

type HashtagSearchItem = {
  tag: string;
  postsCount: number;
};

const emptyPage = <T>(page: number, limit: number): PaginatedResponse<T> =>
  buildPaginatedResponse<T>([], { page, limit, total: 0, offset: (page - 1) * limit });

@Injectable()
export class SearchService {
  constructor(
    @InjectConnection() private readonly connection: Connection,
    @InjectModel(User.name) private readonly userModel: Model<UserDocument>,
    @InjectModel(Post.name) private readonly postModel: Model<PostDocument>,
    private readonly blocksRepository: BlocksRepository,
  ) {}

  async globalSearch(currentUserId: string, query: SearchQueryDto) {
    const normalized = this.normalizeSearchText(query.q);
    const type = query.type ?? 'all';
    const page = query.page ?? 1;
    const limit = query.limit ?? 20;

    const [users, posts, hashtags] = await Promise.all([
      type === 'all' || type === 'users'
        ? this.searchUsers(currentUserId, { ...query, q: normalized })
        : Promise.resolve(emptyPage<SearchUserItem>(page, limit)),
      type === 'all' || type === 'posts'
        ? this.searchPosts(currentUserId, { ...query, q: normalized })
        : Promise.resolve(emptyPage<any>(page, limit)),
      type === 'all' || type === 'hashtags'
        ? this.searchHashtags(currentUserId, { ...query, q: normalized })
        : Promise.resolve(emptyPage<HashtagSearchItem>(page, limit)),
    ]);

    return {
      query: normalized,
      type,
      users,
      posts,
      hashtags,
    };
  }

  async searchUsers(
    currentUserId: string,
    query: SearchQueryDto,
  ): Promise<PaginatedResponse<SearchUserItem>> {
    const normalized = this.normalizeSearchText(query.q);
    const page = query.page ?? 1;
    const limit = query.limit ?? 20;
    const skip = (page - 1) * limit;
    const regex = this.safeRegex(normalized);
    const excludedIds = await this.getBlockedOrBlockingObjectIds(currentUserId);

    const filter: FilterQuery<UserDocument> = {
      isDisabled: false,
      ...(excludedIds.length ? { _id: { $nin: excludedIds } } : {}),
      $or: [{ username: regex }, { name: regex }, { stageName: regex }],
    };

    const [users, total] = await Promise.all([
      this.userModel
        .find(filter)
        .sort({ isVerified: -1, followersCount: -1, username: 1 })
        .skip(skip)
        .limit(limit)
        .exec(),
      this.userModel.countDocuments(filter).exec(),
    ]);

    const followingSet = await this.getFollowingSet(
      currentUserId,
      users.map((user) => user.id),
    );
    const items = users.map((user) => this.toUserItem(user, followingSet));

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

  async searchPosts(currentUserId: string, query: SearchQueryDto): Promise<PaginatedResponse<any>> {
    const normalized = this.normalizeSearchText(query.q);
    const page = query.page ?? 1;
    const limit = query.limit ?? 20;
    const skip = (page - 1) * limit;
    const regex = this.safeRegex(normalized);
    const [blockedIds, followingIds, activeAuthorIds] = await Promise.all([
      this.getBlockedOrBlockingObjectIds(currentUserId),
      this.getFollowingObjectIds(currentUserId),
      this.getActiveSearchableAuthorIds(currentUserId),
    ]);

    const allowedAuthorIds = activeAuthorIds.filter(
      (authorId) => !blockedIds.some((blockedId) => blockedId.equals(authorId)),
    );
    const currentObjectId = new Types.ObjectId(currentUserId);

    const visibilityClauses: FilterQuery<PostDocument>[] = [
      { visibility: PostVisibility.PUBLIC },
      { authorId: currentObjectId },
    ];
    if (followingIds.length) {
      visibilityClauses.push({
        visibility: PostVisibility.FOLLOWERS,
        authorId: { $in: followingIds },
      });
    }

    const filter: FilterQuery<PostDocument> = {
      isDeleted: { $ne: true },
      isArchived: { $ne: true },
      moderationStatus: { $ne: ModerationStatus.HIDDEN },
      authorId: { $in: allowedAuthorIds },
      $and: [
        { $or: visibilityClauses },
        {
          $or: [
            { content: regex },
            { hashtags: regex },
            { style: regex },
            { maqam: regex },
            { rhythmSignature: regex },
          ],
        },
      ],
    };

    const sortDirection = resolveMongoSortDirection(query.sortOrder);
    const [posts, total] = await Promise.all([
      this.postModel
        .find(filter)
        .populate({
          path: 'authorId',
          select: 'name username avatar isVerified stageName isDisabled',
        })
        .populate({ path: 'taggedUserIds', select: 'name username avatar stageName isVerified' })
        .populate({ path: 'collaboratorIds', select: 'name username avatar stageName isVerified' })
        .sort({ createdAt: sortDirection })
        .skip(skip)
        .limit(limit)
        .exec(),
      this.postModel.countDocuments(filter).exec(),
    ]);

    const postIds = posts.map((post) => post.id);
    const authorIds = posts
      .map((post) => this.extractEntityId((post as any).authorId))
      .filter(Boolean);
    const [likedSet, savedSet, followingSet] = await Promise.all([
      this.getLikedPostSet(currentUserId, postIds),
      this.getSavedPostSet(currentUserId, postIds),
      this.getFollowingSet(currentUserId, authorIds),
    ]);

    const items = posts.map((post) => {
      const object = post.toObject();
      const authorId = this.extractEntityId(object.authorId);

      return {
        ...object,
        isLiked: likedSet.has(post.id),
        liked: likedSet.has(post.id),
        isSaved: savedSet.has(post.id),
        saved: savedSet.has(post.id),
        isFollowingAuthor: authorId ? followingSet.has(authorId) : false,
      };
    });

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

  async searchHashtags(
    currentUserId: string,
    query: SearchQueryDto,
  ): Promise<PaginatedResponse<HashtagSearchItem>> {
    const normalized = this.normalizeHashtag(query.q);
    const page = query.page ?? 1;
    const limit = query.limit ?? 20;
    const skip = (page - 1) * limit;
    const regex = this.safeRegex(normalized);
    const [blockedIds, followingIds, activeAuthorIds] = await Promise.all([
      this.getBlockedOrBlockingObjectIds(currentUserId),
      this.getFollowingObjectIds(currentUserId),
      this.getActiveSearchableAuthorIds(currentUserId),
    ]);
    const currentObjectId = new Types.ObjectId(currentUserId);
    const allowedAuthorIds = activeAuthorIds.filter(
      (authorId) => !blockedIds.some((blockedId) => blockedId.equals(authorId)),
    );
    const visibilityOr: Record<string, unknown>[] = [
      { visibility: PostVisibility.PUBLIC },
      { authorId: currentObjectId },
    ];
    if (followingIds.length) {
      visibilityOr.push({ visibility: PostVisibility.FOLLOWERS, authorId: { $in: followingIds } });
    }

    const pipeline: PipelineStage[] = [
      {
        $match: {
          isDeleted: { $ne: true },
          isArchived: { $ne: true },
          moderationStatus: { $ne: ModerationStatus.HIDDEN },
          authorId: { $in: allowedAuthorIds },
          $or: visibilityOr,
          hashtags: regex,
        },
      },
      { $unwind: '$hashtags' },
      { $match: { hashtags: regex } },
      { $group: { _id: '$hashtags', postsCount: { $sum: 1 } } },
      { $sort: { postsCount: -1, _id: 1 } },
      {
        $facet: {
          items: [{ $skip: skip }, { $limit: limit }],
          total: [{ $count: 'count' }],
        },
      },
    ];

    const [result] = await this.postModel.aggregate(pipeline).exec();
    const items = ((result?.items ?? []) as Array<{ _id: string; postsCount: number }>).map(
      (item) => ({
        tag: item._id,
        postsCount: item.postsCount,
      }),
    );
    const total = Number(result?.total?.[0]?.count ?? 0);

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

  async getSuggestions(currentUserId: string, query: SearchSuggestionsQueryDto) {
    const normalized = this.normalizeSearchText(query.q);
    const limit = query.limit ?? 5;
    const [users, hashtags] = await Promise.all([
      this.searchUsers(currentUserId, { q: normalized, page: 1, limit, type: 'users' }),
      this.searchHashtags(currentUserId, { q: normalized, page: 1, limit, type: 'hashtags' }),
    ]);

    return {
      query: normalized,
      users: users.items,
      hashtags: hashtags.items,
    };
  }

  private normalizeSearchText(value: string): string {
    const normalized = (value ?? '').trim();
    if (!normalized) {
      throw new BadRequestException('q is required');
    }
    if (normalized.length > 100) {
      throw new BadRequestException('q must be 100 characters or less');
    }
    return normalized;
  }

  private normalizeHashtag(value: string): string {
    return this.normalizeSearchText(value).replace(/^#+/, '').toLowerCase();
  }

  private safeRegex(value: string): RegExp {
    return new RegExp(this.escapeRegex(value), 'i');
  }

  private escapeRegex(value: string): string {
    return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  }

  private async getBlockedOrBlockingObjectIds(currentUserId: string): Promise<Types.ObjectId[]> {
    const ids = await this.blocksRepository.findBlockingOrBlockedIds(currentUserId);
    return ids.filter((id) => Types.ObjectId.isValid(id)).map((id) => new Types.ObjectId(id));
  }

  private async getFollowingObjectIds(currentUserId: string): Promise<Types.ObjectId[]> {
    const rows = await this.connection
      .collection('follows')
      .find({ followerId: new Types.ObjectId(currentUserId) })
      .project({ followingId: 1 })
      .toArray();

    return rows
      .map((row) => row.followingId)
      .filter((id): id is Types.ObjectId => id instanceof Types.ObjectId);
  }

  private async getFollowingSet(
    currentUserId: string,
    targetUserIds: string[],
  ): Promise<Set<string>> {
    const normalized = Array.from(
      new Set(targetUserIds.filter((id) => Types.ObjectId.isValid(id))),
    );
    if (!normalized.length) {
      return new Set();
    }

    const rows = await this.connection
      .collection('follows')
      .find({
        followerId: new Types.ObjectId(currentUserId),
        followingId: { $in: normalized.map((id) => new Types.ObjectId(id)) },
      })
      .project({ followingId: 1 })
      .toArray();

    return new Set(rows.map((row) => row.followingId?.toString()).filter(Boolean));
  }

  private async getLikedPostSet(currentUserId: string, postIds: string[]): Promise<Set<string>> {
    return this.getViewerPostStateSet('likes', currentUserId, postIds, 'targetId', {
      targetType: 'post',
    });
  }

  private async getSavedPostSet(currentUserId: string, postIds: string[]): Promise<Set<string>> {
    return this.getViewerPostStateSet('saves', currentUserId, postIds, 'postId');
  }

  private async getViewerPostStateSet(
    collectionName: string,
    currentUserId: string,
    postIds: string[],
    postField: 'targetId' | 'postId',
    extraFilter: Record<string, unknown> = {},
  ): Promise<Set<string>> {
    const normalized = Array.from(new Set(postIds.filter((id) => Types.ObjectId.isValid(id))));
    if (!normalized.length) {
      return new Set();
    }

    const rows = await this.connection
      .collection(collectionName)
      .find({
        userId: new Types.ObjectId(currentUserId),
        [postField]: { $in: normalized.map((id) => new Types.ObjectId(id)) },
        ...extraFilter,
      })
      .project({ [postField]: 1 })
      .toArray();

    return new Set(rows.map((row) => row[postField]?.toString()).filter(Boolean));
  }

  private async getActiveSearchableAuthorIds(currentUserId: string): Promise<Types.ObjectId[]> {
    const rows = await this.userModel.find({ isDisabled: false }).select({ _id: 1 }).lean().exec();
    const currentObjectId = new Types.ObjectId(currentUserId);
    const ids = rows
      .map((row) => row._id)
      .filter((id): id is Types.ObjectId => id instanceof Types.ObjectId);

    if (!ids.some((id) => id.equals(currentObjectId))) {
      ids.push(currentObjectId);
    }

    return ids;
  }

  private toUserItem(user: UserDocument, followingSet: Set<string>): SearchUserItem {
    const object = user.toObject();
    return {
      _id: user.id,
      name: object.name ?? '',
      stageName: object.stageName ?? '',
      username: object.username ?? '',
      avatar: object.avatar ?? '',
      isVerified: object.isVerified ?? false,
      isDisabled: object.isDisabled ?? false,
      followersCount: object.followersCount ?? 0,
      followingCount: object.followingCount ?? 0,
      isFollowing: followingSet.has(user.id),
    };
  }

  private extractEntityId(value: unknown): string {
    if (!value) {
      return '';
    }
    if (value instanceof Types.ObjectId) {
      return value.toString();
    }
    if (typeof value === 'object' && '_id' in value) {
      const id = (value as { _id?: unknown })._id;
      return id instanceof Types.ObjectId ? id.toString() : String(id ?? '');
    }
    return String(value);
  }
}
