import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
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 } 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;
};

type SearchEngine = 'auto' | 'atlas' | 'regex';

type AtlasIdFacetResult = {
  items?: Array<{ _id: Types.ObjectId }>;
  total?: Array<{ count: number }>;
};

type AtlasHashtagFacetResult = {
  items?: Array<{ _id: string; postsCount: number }>;
  total?: Array<{ count: number }>;
};

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

@Injectable()
export class SearchService {
  private readonly logger = new Logger(SearchService.name);
  private readonly searchEngine: SearchEngine;
  private readonly atlasUserIndex: string;
  private readonly atlasPostIndex: string;
  private readonly fallbackEnabled: boolean;
  private readonly atlasRetryMs: number;
  private readonly atlasUnavailableUntil = new Map<string, number>();
  private readonly affinityCache = new Map<
    string,
    { expiresAt: number; authorIds: Types.ObjectId[] }
  >();

  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,
    private readonly configService: ConfigService,
  ) {
    this.searchEngine = this.configService.get<SearchEngine>('search.engine') ?? 'auto';
    this.atlasUserIndex = this.configService.get<string>('search.atlasUserIndex') ?? 'users_search';
    this.atlasPostIndex = this.configService.get<string>('search.atlasPostIndex') ?? 'posts_search';
    this.fallbackEnabled = this.configService.get<boolean>('search.fallbackEnabled') ?? true;
    this.atlasRetryMs = (this.configService.get<number>('search.retrySeconds') ?? 300) * 1000;
  }

  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>> {
    return this.runWithSearchEngine(
      'users',
      () => this.searchUsersWithAtlas(currentUserId, query),
      () => this.searchUsersWithRegex(currentUserId, query),
    );
  }

  private async searchUsersWithRegex(
    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>> {
    return this.runWithSearchEngine(
      'posts',
      () => this.searchPostsWithAtlas(currentUserId, query),
      () => this.searchPostsWithRegex(currentUserId, query),
    );
  }

  private async searchPostsWithRegex(
    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, disabledAuthorIds, privateAuthorIds] = await Promise.all([
      this.getBlockedOrBlockingObjectIds(currentUserId),
      this.getFollowingObjectIds(currentUserId),
      this.getDisabledAuthorIds(),
      this.getPrivateAuthorIds(),
    ]);
    const allowedPrivateIds = new Set([currentUserId, ...followingIds.map((id) => id.toString())]);
    const unauthorizedPrivateIds = privateAuthorIds.filter(
      (id) => !allowedPrivateIds.has(id.toString()),
    );
    const excludedAuthorIds = [...blockedIds, ...disabledAuthorIds, ...unauthorizedPrivateIds];
    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 },
      ...(excludedAuthorIds.length ? { authorId: { $nin: excludedAuthorIds } } : {}),
      $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 isPrivate',
        })
        .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 items = await this.toPostSearchItems(currentUserId, posts as unknown as PostDocument[]);

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

  async searchHashtags(
    currentUserId: string,
    query: SearchQueryDto,
  ): Promise<PaginatedResponse<HashtagSearchItem>> {
    return this.runWithSearchEngine(
      'hashtags',
      () => this.searchHashtagsWithAtlas(currentUserId, query),
      () => this.searchHashtagsWithRegex(currentUserId, query),
    );
  }

  private async searchHashtagsWithRegex(
    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, disabledAuthorIds, privateAuthorIds] = await Promise.all([
      this.getBlockedOrBlockingObjectIds(currentUserId),
      this.getFollowingObjectIds(currentUserId),
      this.getDisabledAuthorIds(),
      this.getPrivateAuthorIds(),
    ]);
    const currentObjectId = new Types.ObjectId(currentUserId);
    const allowedPrivateIds = new Set([currentUserId, ...followingIds.map((id) => id.toString())]);
    const excludedAuthorIds = [
      ...blockedIds,
      ...disabledAuthorIds,
      ...privateAuthorIds.filter((id) => !allowedPrivateIds.has(id.toString())),
    ];
    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 },
          ...(excludedAuthorIds.length ? { authorId: { $nin: excludedAuthorIds } } : {}),
          $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 });
  }

  private async searchUsersWithAtlas(
    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 [excludedIds, followingIds, affinityAuthorIds] = await Promise.all([
      this.getBlockedOrBlockingObjectIds(currentUserId),
      this.getFollowingObjectIds(currentUserId),
      this.getInteractedAuthorObjectIds(currentUserId),
    ]);
    const fuzzy =
      normalized.length >= 3 ? { fuzzy: { maxEdits: 1, prefixLength: 1, maxExpansions: 30 } } : {};

    const pipeline = [
      {
        $search: {
          index: this.atlasUserIndex,
          compound: {
            should: [
              {
                text: {
                  query: normalized,
                  path: 'username',
                  score: { boost: { value: 16 } },
                },
              },
              {
                autocomplete: {
                  query: normalized,
                  path: 'username',
                  tokenOrder: 'sequential',
                  ...fuzzy,
                  score: { boost: { value: 12 } },
                },
              },
              {
                autocomplete: {
                  query: normalized,
                  path: 'stageName',
                  tokenOrder: 'sequential',
                  ...fuzzy,
                  score: { boost: { value: 8 } },
                },
              },
              {
                autocomplete: {
                  query: normalized,
                  path: 'name',
                  tokenOrder: 'any',
                  ...fuzzy,
                  score: { boost: { value: 6 } },
                },
              },
              {
                text: {
                  query: normalized,
                  path: ['name', 'stageName'],
                  ...fuzzy,
                  score: { boost: { value: 4 } },
                },
              },
            ],
            minimumShouldMatch: 1,
          },
        },
      },
      {
        $match: {
          isDisabled: false,
          ...(excludedIds.length ? { _id: { $nin: excludedIds } } : {}),
        },
      },
      { $set: { __searchScore: { $meta: 'searchScore' } } },
      {
        $set: {
          __rankScore: {
            $add: [
              { $multiply: ['$__searchScore', 20] },
              { $cond: [{ $in: ['$_id', followingIds] }, 18, 0] },
              { $cond: [{ $in: ['$_id', affinityAuthorIds] }, 10, 0] },
              { $cond: [{ $eq: ['$isVerified', true] }, 6, 0] },
              {
                $multiply: [2, { $log10: { $add: [{ $ifNull: ['$followersCount', 0] }, 1] } }],
              },
            ],
          },
        },
      },
      { $sort: { __rankScore: -1, username: 1 } },
      {
        $facet: {
          items: [{ $skip: skip }, { $limit: limit }, { $project: { _id: 1 } }],
          total: [{ $count: 'count' }],
        },
      },
    ] as unknown as PipelineStage[];

    const [result] = await this.userModel.aggregate<AtlasIdFacetResult>(pipeline).exec();
    const orderedIds = (result?.items ?? []).map((item) => item._id.toString());
    const users = orderedIds.length
      ? await this.userModel.find({ _id: { $in: orderedIds } }).exec()
      : [];
    const usersById = new Map(users.map((user) => [user.id, user]));
    const followingSet = new Set(followingIds.map((id) => id.toString()));
    const orderedUsers = orderedIds.flatMap((id) => {
      const user = usersById.get(id);
      return user ? [user as unknown as UserDocument] : [];
    });
    const items = orderedUsers.map((user) => this.toUserItem(user, followingSet));
    const total = Number(result?.total?.[0]?.count ?? 0);

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

  private async searchPostsWithAtlas(
    currentUserId: string,
    query: SearchQueryDto,
  ): Promise<PaginatedResponse<any>> {
    const normalized = this.normalizeSearchText(query.q);
    const hashtagQuery = this.normalizeHashtag(normalized);
    const page = query.page ?? 1;
    const limit = query.limit ?? 20;
    const skip = (page - 1) * limit;
    const sortDirection = resolveMongoSortDirection(query.sortOrder);
    const [blockedIds, followingIds, affinityAuthorIds, privateAuthorIds] = await Promise.all([
      this.getBlockedOrBlockingObjectIds(currentUserId),
      this.getFollowingObjectIds(currentUserId),
      this.getInteractedAuthorObjectIds(currentUserId),
      this.getPrivateAuthorIds(),
    ]);
    const allowedPrivateIds = new Set([currentUserId, ...followingIds.map((id) => id.toString())]);
    const excludedAuthorIds = [
      ...blockedIds,
      ...privateAuthorIds.filter((id) => !allowedPrivateIds.has(id.toString())),
    ];
    const currentObjectId = new Types.ObjectId(currentUserId);
    const visibilityOr: Record<string, unknown>[] = [
      { visibility: PostVisibility.PUBLIC },
      { authorId: currentObjectId },
    ];
    if (followingIds.length) {
      visibilityOr.push({
        visibility: PostVisibility.FOLLOWERS,
        authorId: { $in: followingIds },
      });
    }
    const fuzzy =
      normalized.length >= 4 ? { fuzzy: { maxEdits: 1, prefixLength: 2, maxExpansions: 40 } } : {};

    const pipeline = [
      {
        $search: {
          index: this.atlasPostIndex,
          compound: {
            should: [
              {
                text: {
                  query: normalized,
                  path: ['content', 'contentTop', 'contentBottom'],
                  ...fuzzy,
                  score: { boost: { value: 6 } },
                },
              },
              {
                text: {
                  query: hashtagQuery,
                  path: 'hashtags',
                  ...fuzzy,
                  score: { boost: { value: 10 } },
                },
              },
              {
                autocomplete: {
                  query: hashtagQuery,
                  path: 'hashtags',
                  tokenOrder: 'sequential',
                  ...fuzzy,
                  score: { boost: { value: 8 } },
                },
              },
              {
                text: {
                  query: normalized,
                  path: ['style', 'maqam', 'rhythmSignature'],
                  ...fuzzy,
                  score: { boost: { value: 5 } },
                },
              },
            ],
            minimumShouldMatch: 1,
          },
        },
      },
      {
        $match: {
          isDeleted: { $ne: true },
          isArchived: { $ne: true },
          moderationStatus: { $ne: ModerationStatus.HIDDEN },
          ...(excludedAuthorIds.length ? { authorId: { $nin: excludedAuthorIds } } : {}),
          $or: visibilityOr,
        },
      },
      {
        $lookup: {
          from: 'users',
          localField: 'authorId',
          foreignField: '_id',
          as: '__searchAuthor',
        },
      },
      { $match: { '__searchAuthor.0.isDisabled': false } },
      { $set: { __searchScore: { $meta: 'searchScore' } } },
      {
        $set: {
          __engagementValue: {
            $add: [
              { $ifNull: ['$likesCount', 0] },
              { $multiply: [{ $ifNull: ['$commentsCount', 0] }, 2] },
              { $multiply: [{ $ifNull: ['$savesCount', 0] }, 3] },
              { $multiply: [{ $ifNull: ['$shareCount', 0] }, 2] },
              { $multiply: [{ $ifNull: ['$viewCount', 0] }, 0.02] },
              { $multiply: [{ $ifNull: ['$playCount', 0] }, 0.03] },
            ],
          },
          __ageHours: {
            $max: [0, { $dateDiff: { startDate: '$createdAt', endDate: '$$NOW', unit: 'hour' } }],
          },
        },
      },
      {
        $set: {
          __rankScore: {
            $add: [
              { $multiply: ['$__searchScore', 20] },
              { $cond: [{ $in: ['$authorId', followingIds] }, 14, 0] },
              { $cond: [{ $in: ['$authorId', affinityAuthorIds] }, 8, 0] },
              { $multiply: [2.5, { $log10: { $add: ['$__engagementValue', 1] } }] },
              { $divide: [12, { $add: [1, { $divide: ['$__ageHours', 168] }] }] },
            ],
          },
        },
      },
      { $sort: { __rankScore: -1, createdAt: sortDirection } },
      {
        $facet: {
          items: [{ $skip: skip }, { $limit: limit }, { $project: { _id: 1 } }],
          total: [{ $count: 'count' }],
        },
      },
    ] as unknown as PipelineStage[];

    const [result] = await this.postModel.aggregate<AtlasIdFacetResult>(pipeline).exec();
    const orderedIds = (result?.items ?? []).map((item) => item._id.toString());
    const posts = orderedIds.length
      ? await this.postModel
          .find({ _id: { $in: orderedIds } })
          .populate({
            path: 'authorId',
            select: 'name username avatar isVerified stageName isDisabled isPrivate',
          })
          .populate({ path: 'taggedUserIds', select: 'name username avatar stageName isVerified' })
          .populate({
            path: 'collaboratorIds',
            select: 'name username avatar stageName isVerified',
          })
          .exec()
      : [];
    const postsById = new Map(posts.map((post) => [post.id, post]));
    const orderedPosts = orderedIds.flatMap((id) => {
      const post = postsById.get(id);
      return post ? [post as unknown as PostDocument] : [];
    });
    const items = await this.toPostSearchItems(currentUserId, orderedPosts);
    const total = Number(result?.total?.[0]?.count ?? 0);

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

  private async searchHashtagsWithAtlas(
    currentUserId: string,
    query: SearchQueryDto,
  ): Promise<PaginatedResponse<HashtagSearchItem>> {
    const normalized = this.normalizeHashtag(query.q);
    if (normalized.length < 2) {
      return this.searchHashtagsWithRegex(currentUserId, query);
    }
    const page = query.page ?? 1;
    const limit = query.limit ?? 20;
    const skip = (page - 1) * limit;
    const regex = this.safeRegex(normalized);
    const [blockedIds, followingIds, privateAuthorIds] = await Promise.all([
      this.getBlockedOrBlockingObjectIds(currentUserId),
      this.getFollowingObjectIds(currentUserId),
      this.getPrivateAuthorIds(),
    ]);
    const allowedPrivateIds = new Set([currentUserId, ...followingIds.map((id) => id.toString())]);
    const excludedAuthorIds = [
      ...blockedIds,
      ...privateAuthorIds.filter((id) => !allowedPrivateIds.has(id.toString())),
    ];
    const currentObjectId = new Types.ObjectId(currentUserId);
    const visibilityOr: Record<string, unknown>[] = [
      { visibility: PostVisibility.PUBLIC },
      { authorId: currentObjectId },
    ];
    if (followingIds.length) {
      visibilityOr.push({
        visibility: PostVisibility.FOLLOWERS,
        authorId: { $in: followingIds },
      });
    }

    const pipeline = [
      {
        $search: {
          index: this.atlasPostIndex,
          autocomplete: {
            query: normalized,
            path: 'hashtags',
            tokenOrder: 'sequential',
          },
        },
      },
      {
        $match: {
          isDeleted: { $ne: true },
          isArchived: { $ne: true },
          moderationStatus: { $ne: ModerationStatus.HIDDEN },
          ...(excludedAuthorIds.length ? { authorId: { $nin: excludedAuthorIds } } : {}),
          $or: visibilityOr,
        },
      },
      {
        $lookup: {
          from: 'users',
          localField: 'authorId',
          foreignField: '_id',
          as: '__searchAuthor',
        },
      },
      { $match: { '__searchAuthor.0.isDisabled': false } },
      { $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' }],
        },
      },
    ] as unknown as PipelineStage[];

    const [result] = await this.postModel.aggregate<AtlasHashtagFacetResult>(pipeline).exec();
    const items = (result?.items ?? []).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 async runWithSearchEngine<T>(
    operation: string,
    atlasSearch: () => Promise<T>,
    fallbackSearch: () => Promise<T>,
  ): Promise<T> {
    const capability = operation === 'users' ? 'users' : 'posts';
    const unavailableUntil = this.atlasUnavailableUntil.get(capability) ?? 0;
    if (this.searchEngine === 'regex') {
      return fallbackSearch();
    }
    if (this.fallbackEnabled && Date.now() < unavailableUntil) {
      return fallbackSearch();
    }

    try {
      const result = await atlasSearch();
      this.atlasUnavailableUntil.delete(capability);
      return result;
    } catch (error) {
      if (!this.fallbackEnabled) {
        throw error;
      }

      const wasAvailable = Date.now() >= unavailableUntil;
      this.atlasUnavailableUntil.set(capability, Date.now() + this.atlasRetryMs);
      if (wasAvailable) {
        const reason = error instanceof Error ? error.message.slice(0, 300) : String(error);
        this.logger.warn(
          `Atlas Search unavailable during ${operation}; using compatibility search for ` +
            `${Math.round(this.atlasRetryMs / 1000)}s. Reason: ${reason}`,
        );
      }
      return fallbackSearch();
    }
  }

  private async toPostSearchItems(currentUserId: string, posts: PostDocument[]): Promise<any[]> {
    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 safeObjects = await this.redactSearchOriginalReferences(
      currentUserId,
      posts.map((post) => post.toObject() as Record<string, any>),
    );

    return posts.map((post, index) => {
      const object = safeObjects[index];
      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,
      };
    });
  }

  private async redactSearchOriginalReferences(
    currentUserId: string,
    items: Array<Record<string, any>>,
  ): Promise<Array<Record<string, any>>> {
    const fields = ['repostOfPostId', 'quoteOfPostId'] as const;
    const sourceIds = Array.from(
      new Set(
        items
          .flatMap((item) => fields.map((field) => this.extractEntityId(item[field])))
          .filter((id) => Types.ObjectId.isValid(id)),
      ),
    );
    if (!sourceIds.length) {
      return items;
    }
    const [sources, followingIds, blockedIds, privateAuthorIds] = await Promise.all([
      this.connection
        .collection('posts')
        .find({
          _id: { $in: sourceIds.map((id) => new Types.ObjectId(id)) },
          isDeleted: { $ne: true },
          isArchived: { $ne: true },
          moderationStatus: { $ne: ModerationStatus.HIDDEN },
        })
        .project({ authorId: 1, visibility: 1 })
        .toArray(),
      this.getFollowingObjectIds(currentUserId),
      this.getBlockedOrBlockingObjectIds(currentUserId),
      this.getPrivateAuthorIds(),
    ]);
    const followingSet = new Set(followingIds.map((id) => id.toString()));
    const blockedSet = new Set(blockedIds.map((id) => id.toString()));
    const privateSet = new Set(privateAuthorIds.map((id) => id.toString()));
    const sourceById = new Map(sources.map((source) => [source._id.toString(), source]));

    return items.map((item) => {
      const safe = { ...item };
      for (const field of fields) {
        const sourceId = this.extractEntityId(item[field]);
        if (!sourceId) continue;
        const source = sourceById.get(sourceId);
        const authorId = this.extractEntityId(source?.authorId);
        const accountAllowed =
          authorId === currentUserId ||
          (!!authorId && !privateSet.has(authorId)) ||
          (!!authorId && followingSet.has(authorId));
        const visibilityAllowed =
          authorId === currentUserId ||
          source?.visibility === PostVisibility.PUBLIC ||
          (source?.visibility === PostVisibility.FOLLOWERS && followingSet.has(authorId));
        if (!source || !authorId || blockedSet.has(authorId) || !accountAllowed || !visibilityAllowed) {
          safe[field] = null;
        }
      }
      return safe;
    });
  }

  private async getInteractedAuthorObjectIds(currentUserId: string): Promise<Types.ObjectId[]> {
    const cached = this.affinityCache.get(currentUserId);
    if (cached && cached.expiresAt > Date.now()) {
      return cached.authorIds;
    }

    const userObjectId = new Types.ObjectId(currentUserId);
    const [likedRows, savedRows] = await Promise.all([
      this.connection
        .collection('likes')
        .find({ userId: userObjectId, targetType: 'post' })
        .sort({ createdAt: -1 })
        .limit(75)
        .project({ targetId: 1 })
        .toArray(),
      this.connection
        .collection('saves')
        .find({ userId: userObjectId })
        .sort({ createdAt: -1 })
        .limit(75)
        .project({ postId: 1 })
        .toArray(),
    ]);
    const postIds = Array.from(
      new Set(
        [...likedRows.map((row) => row.targetId), ...savedRows.map((row) => row.postId)]
          .filter((id): id is Types.ObjectId => id instanceof Types.ObjectId)
          .map((id) => id.toString()),
      ),
    ).map((id) => new Types.ObjectId(id));
    const rows = postIds.length
      ? await this.postModel
          .find({ _id: { $in: postIds }, isDeleted: { $ne: true } })
          .select({ authorId: 1 })
          .lean()
          .exec()
      : [];
    const authorIds = Array.from(
      new Set(
        rows
          .map((row) => row.authorId)
          .filter((id): id is Types.ObjectId => id instanceof Types.ObjectId)
          .map((id) => id.toString()),
      ),
    ).map((id) => new Types.ObjectId(id));

    if (this.affinityCache.size >= 1000) {
      const oldestKey = this.affinityCache.keys().next().value as string | undefined;
      if (oldestKey) {
        this.affinityCache.delete(oldestKey);
      }
    }
    this.affinityCache.set(currentUserId, {
      expiresAt: Date.now() + 60_000,
      authorIds,
    });
    return authorIds;
  }

  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 getDisabledAuthorIds(): Promise<Types.ObjectId[]> {
    const rows = await this.userModel.find({ isDisabled: true }).select({ _id: 1 }).lean().exec();
    return rows
      .map((row) => row._id)
      .filter((id): id is Types.ObjectId => id instanceof Types.ObjectId);
  }

  private async getPrivateAuthorIds(): Promise<Types.ObjectId[]> {
    const rows = await this.userModel
      .find({ isPrivate: true, isDisabled: false })
      .select({ _id: 1 })
      .lean()
      .exec();
    return rows
      .map((row) => row._id)
      .filter((id): id is Types.ObjectId => id instanceof Types.ObjectId);
  }

  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);
  }
}
