import { Types } from 'mongoose';
import { SearchService } from './search.service';

const createService = (engine: 'auto' | 'atlas' | 'regex' = 'atlas') => {
  const connection = {
    collection: jest.fn(),
  };
  const userModel: any = {
    aggregate: jest.fn(),
    find: jest.fn(() => ({
      select: jest.fn().mockReturnThis(),
      lean: jest.fn().mockReturnThis(),
      exec: jest.fn().mockResolvedValue([]),
    })),
    countDocuments: jest.fn(),
  };
  const postModel = {
    aggregate: jest.fn(),
    find: jest.fn(),
    countDocuments: jest.fn(),
  };
  const config = {
    get: jest.fn((key: string) => {
      const values: Record<string, unknown> = {
        'search.engine': engine,
        'search.atlasUserIndex': 'users_search_test',
        'search.atlasPostIndex': 'posts_search_test',
        'search.fallbackEnabled': true,
        'search.retrySeconds': 300,
      };
      return values[key];
    }),
  };
  const service = new SearchService(
    connection as any,
    userModel as any,
    postModel as any,
    { findBlockingOrBlockedIds: jest.fn().mockResolvedValue([]) } as any,
    config as any,
  );

  return { service, connection, userModel, postModel };
};

describe('SearchService advanced search compatibility', () => {
  const viewerId = '507f1f77bcf86cd799439011';

  it('normalizes global queries and calls only the requested search scope', async () => {
    const { service } = createService();
    const empty = {
      items: [],
      total: 0,
      page: 2,
      limit: 3,
      count: 0,
      totalPages: 1,
      nextCursor: null,
      pagination: {},
    } as any;
    const users = jest.spyOn(service, 'searchUsers').mockResolvedValue(empty);
    const posts = jest.spyOn(service, 'searchPosts').mockResolvedValue(empty);
    const hashtags = jest.spyOn(service, 'searchHashtags').mockResolvedValue(empty);

    const result = await service.globalSearch(viewerId, {
      q: '  oud  ',
      type: 'users',
      page: 2,
      limit: 3,
    });

    expect(result).toMatchObject({ query: 'oud', type: 'users', users: empty });
    expect(users).toHaveBeenCalledWith(viewerId, expect.objectContaining({ q: 'oud' }));
    expect(posts).not.toHaveBeenCalled();
    expect(hashtags).not.toHaveBeenCalled();
    expect(result.posts.items).toEqual([]);

    await service.globalSearch(viewerId, { q: 'all' });
    expect(posts).toHaveBeenCalled();
    expect(hashtags).toHaveBeenCalled();
  });

  it('rejects missing and oversized search text before touching storage', async () => {
    const { service, userModel } = createService('regex');
    await expect(service.searchUsers(viewerId, { q: '   ' })).rejects.toThrow('q is required');
    await expect(service.searchUsers(viewerId, { q: 'x'.repeat(101) })).rejects.toThrow(
      'q must be 100 characters or less',
    );
    expect(userModel.find).not.toHaveBeenCalled();
  });

  it('performs compatibility user search with safe regex, exclusions and viewer follow state', async () => {
    const { service, userModel } = createService('regex');
    const userObjectId = new Types.ObjectId();
    const blockedId = new Types.ObjectId();
    const user = {
      id: userObjectId.toString(),
      toObject: () => ({ username: 'a+b', name: undefined, isVerified: true }),
    };
    const execUsers = jest.fn().mockResolvedValue([user]);
    const chain = {
      sort: jest.fn().mockReturnThis(),
      skip: jest.fn().mockReturnThis(),
      limit: jest.fn().mockReturnThis(),
      exec: execUsers,
    };
    userModel.find.mockReturnValue(chain);
    userModel.countDocuments.mockReturnValue({ exec: jest.fn().mockResolvedValue(1) });
    jest.spyOn(service as any, 'getBlockedOrBlockingObjectIds').mockResolvedValue([blockedId]);
    jest.spyOn(service as any, 'getFollowingSet').mockResolvedValue(new Set([user.id]));

    const result = await service.searchUsers(viewerId, {
      q: ' a+b ',
      page: 2,
      limit: 4,
    });

    const filter = userModel.find.mock.calls[0][0] as Record<string, any>;
    expect(filter._id.$nin).toEqual([blockedId]);
    expect(filter.$or[0].username.source).toBe('a\\+b');
    expect(chain.skip).toHaveBeenCalledWith(4);
    expect(result.items[0]).toMatchObject({
      _id: user.id,
      username: 'a+b',
      name: '',
      isVerified: true,
      isFollowing: true,
      followersCount: 0,
    });
  });

  it('performs compatibility post search with privacy filters and sorting', async () => {
    const { service, postModel } = createService('regex');
    const followed = new Types.ObjectId();
    const blocked = new Types.ObjectId();
    const disabled = new Types.ObjectId();
    const privateAuthor = new Types.ObjectId();
    const postQuery = {
      populate: jest.fn().mockReturnThis(),
      sort: jest.fn().mockReturnThis(),
      skip: jest.fn().mockReturnThis(),
      limit: jest.fn().mockReturnThis(),
      exec: jest.fn().mockResolvedValue([{ id: 'post' }]),
    };
    postModel.find.mockReturnValue(postQuery);
    postModel.countDocuments.mockReturnValue({ exec: jest.fn().mockResolvedValue(1) });
    jest.spyOn(service as any, 'getBlockedOrBlockingObjectIds').mockResolvedValue([blocked]);
    jest.spyOn(service as any, 'getFollowingObjectIds').mockResolvedValue([followed]);
    jest.spyOn(service as any, 'getDisabledAuthorIds').mockResolvedValue([disabled]);
    jest.spyOn(service as any, 'getPrivateAuthorIds').mockResolvedValue([privateAuthor]);
    jest.spyOn(service as any, 'toPostSearchItems').mockResolvedValue([{ id: 'post', isLiked: false }]);

    const result = await service.searchPosts(viewerId, {
      q: 'oud',
      page: 1,
      limit: 10,
      sortOrder: 'asc' as any,
    });

    const filter = postModel.find.mock.calls[0][0] as Record<string, any>;
    expect(filter.authorId.$nin).toEqual([blocked, disabled, privateAuthor]);
    expect(filter.$and[0].$or).toEqual(
      expect.arrayContaining([
        { visibility: 'public' },
        { visibility: 'followers', authorId: { $in: [followed] } },
      ]),
    );
    expect(postQuery.sort).toHaveBeenCalledWith({ createdAt: 1 });
    expect(result.items).toEqual([{ id: 'post', isLiked: false }]);
  });

  it('aggregates compatibility hashtags after normalizing leading hashes', async () => {
    const { service, postModel } = createService('regex');
    const followed = new Types.ObjectId();
    jest.spyOn(service as any, 'getBlockedOrBlockingObjectIds').mockResolvedValue([]);
    jest.spyOn(service as any, 'getFollowingObjectIds').mockResolvedValue([followed]);
    jest.spyOn(service as any, 'getDisabledAuthorIds').mockResolvedValue([]);
    postModel.aggregate.mockReturnValue({
      exec: jest.fn().mockResolvedValue([
        { items: [{ _id: 'oud', postsCount: 9 }], total: [{ count: 1 }] },
      ]),
    });

    const result = await service.searchHashtags(viewerId, { q: ' ##OuD ', page: 1, limit: 5 });
    expect(result.items).toEqual([{ tag: 'oud', postsCount: 9 }]);
    expect(result.total).toBe(1);
    const pipeline = postModel.aggregate.mock.calls[0][0] as Array<Record<string, any>>;
    expect(pipeline[0].$match.hashtags.source).toBe('oud');
    expect(pipeline[0].$match.$or).toEqual(
      expect.arrayContaining([{ visibility: 'followers', authorId: { $in: [followed] } }]),
    );
  });

  it('returns search suggestions from normalized user and hashtag results', async () => {
    const { service } = createService();
    jest.spyOn(service, 'searchUsers').mockResolvedValue({ items: [{ username: 'oud' }] } as any);
    jest.spyOn(service, 'searchHashtags').mockResolvedValue({ items: [{ tag: 'oud' }] } as any);

    await expect(service.getSuggestions(viewerId, { q: '  oud ', limit: 2 })).resolves.toEqual({
      query: 'oud',
      users: [{ username: 'oud' }],
      hashtags: [{ tag: 'oud' }],
    });
    expect(service.searchUsers).toHaveBeenCalledWith(viewerId, {
      q: 'oud',
      page: 1,
      limit: 2,
      type: 'users',
    });
  });

  it('uses regex directly when configured and rethrows Atlas errors when fallback is disabled', async () => {
    const regex = createService('regex').service;
    const atlas = jest.spyOn(regex as any, 'searchUsersWithAtlas');
    const fallback = jest.spyOn(regex as any, 'searchUsersWithRegex').mockResolvedValue({ items: [] });
    await regex.searchUsers(viewerId, { q: 'oud' });
    expect(atlas).not.toHaveBeenCalled();
    expect(fallback).toHaveBeenCalled();

    const strict = createService('atlas').service;
    (strict as any).fallbackEnabled = false;
    jest.spyOn(strict as any, 'searchUsersWithAtlas').mockRejectedValue(new Error('atlas failed'));
    const strictFallback = jest.spyOn(strict as any, 'searchUsersWithRegex');
    await expect(strict.searchUsers(viewerId, { q: 'oud' })).rejects.toThrow('atlas failed');
    expect(strictFallback).not.toHaveBeenCalled();
  });

  it('preserves Atlas post order, drops stale ids and decorates returned posts', async () => {
    const { service, postModel } = createService('atlas');
    const firstId = new Types.ObjectId();
    const staleId = new Types.ObjectId();
    const post = { id: firstId.toString() };
    jest.spyOn(service as any, 'getBlockedOrBlockingObjectIds').mockResolvedValue([]);
    jest.spyOn(service as any, 'getFollowingObjectIds').mockResolvedValue([]);
    jest.spyOn(service as any, 'getInteractedAuthorObjectIds').mockResolvedValue([]);
    jest.spyOn(service as any, 'toPostSearchItems').mockResolvedValue([{ id: post.id, decorated: true }]);
    postModel.aggregate.mockReturnValue({
      exec: jest.fn().mockResolvedValue([
        { items: [{ _id: staleId }, { _id: firstId }], total: [{ count: 2 }] },
      ]),
    });
    const query = {
      populate: jest.fn().mockReturnThis(),
      exec: jest.fn().mockResolvedValue([post]),
    };
    postModel.find.mockReturnValue(query);

    const result = await service.searchPosts(viewerId, { q: '#oud', page: 1, limit: 10 });

    expect(query.populate).toHaveBeenCalledTimes(3);
    expect((service as any).toPostSearchItems).toHaveBeenCalledWith(viewerId, [post]);
    expect(result.items).toEqual([{ id: post.id, decorated: true }]);
    expect(result.total).toBe(2);
  });

  it('uses regex for one-character Atlas hashtags and Atlas aggregation for longer terms', async () => {
    const short = createService('atlas').service;
    const fallback = jest
      .spyOn(short as any, 'searchHashtagsWithRegex')
      .mockResolvedValue({ items: [{ tag: 'a' }] });
    await short.searchHashtags(viewerId, { q: '#a' });
    expect(fallback).toHaveBeenCalled();

    const { service, postModel } = createService('atlas');
    jest.spyOn(service as any, 'getBlockedOrBlockingObjectIds').mockResolvedValue([]);
    jest.spyOn(service as any, 'getFollowingObjectIds').mockResolvedValue([]);
    postModel.aggregate.mockReturnValue({
      exec: jest.fn().mockResolvedValue([
        { items: [{ _id: 'oud', postsCount: 4 }], total: [{ count: 1 }] },
      ]),
    });
    const result = await service.searchHashtags(viewerId, { q: '#oud', page: 2, limit: 2 });
    expect(result.items).toEqual([{ tag: 'oud', postsCount: 4 }]);
    const pipeline = postModel.aggregate.mock.calls[0][0] as Array<Record<string, any>>;
    expect(pipeline[0].$search.index).toBe('posts_search_test');
    expect(pipeline.at(-1)?.$facet.items).toEqual([{ $skip: 2 }, { $limit: 2 }]);
  });

  it('decorates post search results with liked, saved and followed state', async () => {
    const { service } = createService();
    const authorId = new Types.ObjectId();
    const firstId = new Types.ObjectId().toString();
    const secondId = new Types.ObjectId().toString();
    const posts = [
      {
        id: firstId,
        authorId,
        toObject: () => ({ _id: firstId, authorId }),
      },
      {
        id: secondId,
        authorId: { _id: authorId },
        toObject: () => ({ _id: secondId, authorId: { _id: authorId } }),
      },
    ];
    jest.spyOn(service as any, 'getLikedPostSet').mockResolvedValue(new Set([firstId]));
    jest.spyOn(service as any, 'getSavedPostSet').mockResolvedValue(new Set([secondId]));
    jest.spyOn(service as any, 'getFollowingSet').mockResolvedValue(new Set([authorId.toString()]));

    const result = await (service as any).toPostSearchItems(viewerId, posts);
    expect(result).toEqual([
      expect.objectContaining({ isLiked: true, liked: true, isSaved: false, isFollowingAuthor: true }),
      expect.objectContaining({ isLiked: false, isSaved: true, saved: true, isFollowingAuthor: true }),
    ]);
  });

  it('loads and caches interaction-based author affinity without duplicate authors', async () => {
    const { service, connection, postModel } = createService();
    const likedPostId = new Types.ObjectId();
    const savedPostId = new Types.ObjectId();
    const authorId = new Types.ObjectId();
    const makeCursor = (rows: any[]) => ({
      sort: jest.fn().mockReturnThis(),
      limit: jest.fn().mockReturnThis(),
      project: jest.fn().mockReturnThis(),
      toArray: jest.fn().mockResolvedValue(rows),
    });
    connection.collection.mockImplementation((name: string) => ({
      find: jest.fn().mockReturnValue(
        name === 'likes'
          ? makeCursor([{ targetId: likedPostId }, { targetId: 'invalid' }])
          : makeCursor([{ postId: savedPostId }, { postId: likedPostId }]),
      ),
    }));
    const postQuery = {
      select: jest.fn().mockReturnThis(),
      lean: jest.fn().mockReturnThis(),
      exec: jest.fn().mockResolvedValue([
        { authorId },
        { authorId },
        { authorId: 'invalid' },
      ]),
    };
    postModel.find.mockReturnValue(postQuery);

    const first = await (service as any).getInteractedAuthorObjectIds(viewerId);
    const second = await (service as any).getInteractedAuthorObjectIds(viewerId);
    expect(first.map(String)).toEqual([authorId.toString()]);
    expect(second).toBe(first);
    expect(connection.collection).toHaveBeenCalledTimes(2);
    expect(postModel.find).toHaveBeenCalledTimes(1);
  });

  it('queries follow and viewer-state collections with normalized valid ids', async () => {
    const { service, connection, userModel } = createService();
    const followedId = new Types.ObjectId();
    const postId = new Types.ObjectId();
    const collectionRows: Record<string, any[]> = {
      follows: [{ followingId: followedId }, { followingId: 'bad' }],
      likes: [{ targetId: postId }],
      saves: [{ postId }],
    };
    connection.collection.mockImplementation((name: string) => ({
      find: jest.fn().mockReturnValue({
        project: jest.fn().mockReturnThis(),
        toArray: jest.fn().mockResolvedValue(collectionRows[name] ?? []),
      }),
    }));
    userModel.find.mockReturnValue({
      select: jest.fn().mockReturnThis(),
      lean: jest.fn().mockReturnThis(),
      exec: jest.fn().mockResolvedValue([{ _id: followedId }, { _id: 'bad' }]),
    });

    await expect((service as any).getFollowingObjectIds(viewerId)).resolves.toEqual([followedId]);
    await expect(
      (service as any).getFollowingSet(viewerId, [followedId.toString(), followedId.toString(), 'bad']),
    ).resolves.toEqual(new Set([followedId.toString(), 'bad']));
    await expect((service as any).getFollowingSet(viewerId, ['bad'])).resolves.toEqual(new Set());
    await expect((service as any).getLikedPostSet(viewerId, [postId.toString(), 'bad'])).resolves.toEqual(
      new Set([postId.toString()]),
    );
    await expect((service as any).getSavedPostSet(viewerId, [postId.toString()])).resolves.toEqual(
      new Set([postId.toString()]),
    );
    await expect((service as any).getSavedPostSet(viewerId, ['bad'])).resolves.toEqual(new Set());
    await expect((service as any).getDisabledAuthorIds()).resolves.toEqual([followedId]);
  });
  it('keeps ordered user results and pagination while ranking with Atlas Search', async () => {
    const { service, userModel } = createService();
    const firstId = new Types.ObjectId();
    const secondId = new Types.ObjectId();
    const makeUser = (id: Types.ObjectId, username: string) => ({
      id: id.toString(),
      toObject: () => ({
        _id: id,
        username,
        name: username,
        stageName: '',
        avatar: '',
        isVerified: false,
        isDisabled: false,
        followersCount: 0,
        followingCount: 0,
      }),
    });
    const users = [makeUser(firstId, 'oud-first'), makeUser(secondId, 'oud-second')];

    jest.spyOn(service as any, 'getBlockedOrBlockingObjectIds').mockResolvedValue([]);
    jest.spyOn(service as any, 'getFollowingObjectIds').mockResolvedValue([secondId]);
    jest.spyOn(service as any, 'getInteractedAuthorObjectIds').mockResolvedValue([]);
    userModel.aggregate.mockReturnValue({
      exec: jest
        .fn()
        .mockResolvedValue([
          { items: [{ _id: secondId }, { _id: firstId }], total: [{ count: 2 }] },
        ]),
    });
    userModel.find.mockReturnValue({ exec: jest.fn().mockResolvedValue(users) });

    const result = await service.searchUsers('507f1f77bcf86cd799439011', {
      q: 'oud',
      page: 1,
      limit: 20,
    });

    expect(result.items.map((item) => item.username)).toEqual(['oud-second', 'oud-first']);
    expect(result.items[0].isFollowing).toBe(true);
    expect(result.total).toBe(2);
    expect(result.pagination).toMatchObject({ page: 1, limit: 20, total: 2 });

    const pipeline = userModel.aggregate.mock.calls[0][0] as Array<Record<string, any>>;
    expect(pipeline[0].$search.index).toBe('users_search_test');
    expect(pipeline[0].$search.compound.minimumShouldMatch).toBe(1);
    expect(pipeline.some((stage) => stage.$set?.__rankScore)).toBe(true);
  });

  it('keeps privacy filters inside advanced post search', async () => {
    const { service, postModel } = createService();
    const followedId = new Types.ObjectId();
    const privateAuthorId = new Types.ObjectId();

    jest.spyOn(service as any, 'getBlockedOrBlockingObjectIds').mockResolvedValue([]);
    jest.spyOn(service as any, 'getFollowingObjectIds').mockResolvedValue([followedId]);
    jest.spyOn(service as any, 'getInteractedAuthorObjectIds').mockResolvedValue([]);
    jest.spyOn(service as any, 'getPrivateAuthorIds').mockResolvedValue([privateAuthorId]);
    postModel.aggregate.mockReturnValue({
      exec: jest.fn().mockResolvedValue([{ items: [], total: [{ count: 0 }] }]),
    });

    const result = await service.searchPosts('507f1f77bcf86cd799439011', {
      q: 'عود',
      page: 1,
      limit: 20,
    });

    expect(result.items).toEqual([]);
    const pipeline = postModel.aggregate.mock.calls[0][0] as Array<Record<string, any>>;
    const privacyMatch = pipeline.find((stage) => stage.$match)?.$match;
    expect(privacyMatch).toMatchObject({
      isDeleted: { $ne: true },
      isArchived: { $ne: true },
      moderationStatus: { $ne: 'hidden' },
      authorId: { $nin: [privateAuthorId] },
    });
    expect(privacyMatch.$or).toEqual(
      expect.arrayContaining([
        { visibility: 'public' },
        { visibility: 'followers', authorId: { $in: [followedId] } },
      ]),
    );
    expect(pipeline.some((stage) => stage.$set?.__rankScore)).toBe(true);
  });

  it('falls back without changing the response and throttles Atlas retries', async () => {
    const { service } = createService('auto');
    const compatibleResult = {
      items: [],
      count: 0,
      page: 1,
      limit: 20,
      total: 0,
      totalPages: 1,
      nextCursor: null,
      pagination: {
        mode: 'offset' as const,
        page: 1,
        limit: 20,
        count: 0,
        total: 0,
        totalPages: 1,
        hasNextPage: false,
        hasPreviousPage: false,
        nextPage: null,
        previousPage: null,
        currentCursor: null,
        nextCursor: null,
      },
    };
    const atlas = jest
      .spyOn(service as any, 'searchUsersWithAtlas')
      .mockRejectedValue(new Error('search index unavailable'));
    const fallback = jest
      .spyOn(service as any, 'searchUsersWithRegex')
      .mockResolvedValue(compatibleResult);

    await expect(service.searchUsers('viewer', { q: 'oud' })).resolves.toBe(compatibleResult);
    await expect(service.searchUsers('viewer', { q: 'oud' })).resolves.toBe(compatibleResult);

    expect(atlas).toHaveBeenCalledTimes(1);
    expect(fallback).toHaveBeenCalledTimes(2);
  });
});
