import { Types } from 'mongoose';
import { ModerationStatus } from '../../common/enums/moderation-status.enum';
import { SortOrder } from '../../common/enums/sort-order.enum';
import { CommentSortBy } from './dto/comment-query.dto';
import { CommentsService } from './comments.service';

describe('CommentsService', () => {
  const userId = '507f1f77bcf86cd799439011';
  const ownerId = '507f191e810c19729de860ea';
  const postId = '507f1f77bcf86cd799439012';
  const commentId = '507f191e810c19729de860eb';

  const setup = (overrides: Record<string, Record<string, jest.Mock>> = {}) => {
    const commentsRepository = {
      findById: jest.fn().mockResolvedValue(null),
      findByIdWithAuthor: jest.fn().mockResolvedValue(null),
      create: jest.fn().mockImplementation(async (value) => ({ id: commentId, ...value })),
      setPinned: jest.fn().mockResolvedValue({ id: commentId }),
      deleteById: jest.fn().mockResolvedValue(true),
      updateById: jest.fn().mockResolvedValue(null),
      updateModerationStatus: jest.fn().mockResolvedValue(null),
      countByPost: jest.fn().mockResolvedValue(4),
      findMany: jest.fn().mockResolvedValue([]),
      findManyTop: jest.fn().mockResolvedValue([]),
      count: jest.fn().mockResolvedValue(0),
      findManyAdmin: jest.fn().mockResolvedValue([]),
      countAdmin: jest.fn().mockResolvedValue(0),
      countRepliesByParentIds: jest.fn().mockResolvedValue({}),
      countLikesByCommentIds: jest.fn().mockResolvedValue({}),
      findLikedCommentIds: jest.fn().mockResolvedValue([]),
      findReplyPreviewsByParentIds: jest.fn().mockResolvedValue({}),
      ...overrides.commentsRepository,
    };
    const postsRepository = {
      findById: jest.fn().mockResolvedValue({
        id: postId,
        authorId: ownerId,
        commentsDisabled: false,
        commentsFollowersOnly: false,
        commentFilterKeywords: [],
      }),
      setCommentsCount: jest.fn().mockResolvedValue(undefined),
      ...overrides.postsRepository,
    };
    const auditService = {
      logSuperAdminAction: jest.fn().mockResolvedValue(undefined),
      ...overrides.auditService,
    };
    const feedVersionService = {
      bumpGlobalVersion: jest.fn().mockResolvedValue(1),
      bumpUserVersion: jest.fn().mockResolvedValue(1),
      ...overrides.feedVersionService,
    };
    const notificationsService = {
      createCommentNotification: jest.fn().mockResolvedValue(undefined),
      createMentionNotification: jest.fn().mockResolvedValue(undefined),
      ...overrides.notificationsService,
    };
    const usersRepository = {
      findByUsernames: jest.fn().mockResolvedValue([]),
      ...overrides.usersRepository,
    };
    const followsRepository = {
      findOne: jest.fn().mockResolvedValue(null),
      ...overrides.followsRepository,
    };
    const blocksRepository = {
      findAnyBetween: jest.fn().mockResolvedValue(null),
      ...overrides.blocksRepository,
    };
    const service = new CommentsService(
      commentsRepository as any,
      postsRepository as any,
      auditService as any,
      feedVersionService as any,
      notificationsService as any,
      usersRepository as any,
      followsRepository as any,
      blocksRepository as any,
    );
    return {
      service,
      commentsRepository,
      postsRepository,
      auditService,
      feedVersionService,
      notificationsService,
      usersRepository,
      followsRepository,
      blocksRepository,
    };
  };

  it('creates a trimmed filtered comment and notifies unique post, parent and mention recipients', async () => {
    const parentId = '507f191e810c19729de860ec';
    const parentOwnerId = '507f191e810c19729de860ed';
    const mentionedId = '507f191e810c19729de860ee';
    const ctx = setup({
      postsRepository: {
        findById: jest.fn().mockResolvedValue({
          authorId: ownerId,
          commentsDisabled: false,
          commentsFollowersOnly: false,
          commentFilterKeywords: [' SPAM '],
        }),
      },
      commentsRepository: {
        findById: jest.fn().mockResolvedValue({
          postId: { toString: () => postId },
          authorId: { toString: () => parentOwnerId },
        }),
      },
      usersRepository: {
        findByUsernames: jest.fn().mockResolvedValue([
          { id: mentionedId, username: 'friend' },
          { id: userId, username: 'self' },
        ]),
      },
    });

    await ctx.service.create(userId, {
      postId,
      parentCommentId: parentId,
      content: '  This has spam @Friend @self  ',
      mentionUsernames: ['@FRIEND', ' friend '],
    });

    expect(ctx.commentsRepository.create).toHaveBeenCalledWith({
      postId,
      authorId: userId,
      content: 'This has spam @Friend @self',
      mentionUsernames: ['friend'],
      parentCommentId: parentId,
      hiddenByFilter: true,
      hiddenReason: 'keyword_filter',
    });
    expect(ctx.postsRepository.setCommentsCount).toHaveBeenCalledWith(postId, 4);
    expect(ctx.notificationsService.createCommentNotification).toHaveBeenCalledTimes(2);
    expect(ctx.notificationsService.createMentionNotification).toHaveBeenCalledWith(
      userId,
      mentionedId,
      postId,
      expect.objectContaining({ resourceType: 'comment', deepLink: `/posts/${postId}` }),
    );
  });

  it('validates post and parent comment relationships before creating', async () => {
    const missingPost = setup({ postsRepository: { findById: jest.fn().mockResolvedValue(null) } });
    await expect(missingPost.service.create(userId, { postId, content: 'hello' })).rejects.toThrow(
      'Post not found',
    );

    const missingParent = setup();
    await expect(
      missingParent.service.create(userId, { postId, content: 'reply', parentCommentId: commentId }),
    ).rejects.toThrow('Parent comment not found');

    const wrongPost = setup({
      commentsRepository: {
        findById: jest.fn().mockResolvedValue({
          postId: { toString: () => '507f191e810c19729de860ff' },
          authorId: ownerId,
        }),
      },
    });
    await expect(
      wrongPost.service.create(userId, { postId, content: 'reply', parentCommentId: commentId }),
    ).rejects.toThrow('Parent comment not found');
  });

  it('enforces disabled and followers-only comment settings', async () => {
    const disabled = setup({
      postsRepository: {
        findById: jest.fn().mockResolvedValue({ authorId: ownerId, commentsDisabled: true }),
      },
    });
    await expect(disabled.service.create(userId, { postId, content: 'hello' })).rejects.toThrow(
      'Comments are disabled',
    );

    const followersOnly = setup({
      postsRepository: {
        findById: jest.fn().mockResolvedValue({
          authorId: ownerId,
          commentsDisabled: false,
          commentsFollowersOnly: true,
        }),
      },
    });
    await expect(followersOnly.service.create(userId, { postId, content: 'hello' })).rejects.toThrow(
      'Only followers can comment',
    );

    const follower = setup({
      postsRepository: {
        findById: jest.fn().mockResolvedValue({
          authorId: ownerId,
          commentsDisabled: false,
          commentsFollowersOnly: true,
        }),
      },
      followsRepository: { findOne: jest.fn().mockResolvedValue({ id: 'follow' }) },
    });
    await expect(follower.service.create(userId, { postId, content: 'hello' })).resolves.toBeDefined();
  });

  it('limits comment mentions to thirty users', async () => {
    const ctx = setup();
    const mentions = Array.from({ length: 31 }, (_, index) => `user${index}`).join(' @');
    await expect(ctx.service.create(userId, { postId, content: `@${mentions}` })).rejects.toThrow(
      'You can mention up to 30 users only',
    );
    expect(ctx.commentsRepository.create).not.toHaveBeenCalled();
  });

  it.each([
    ['pin', true, 'Only the post owner can pin comments'],
    ['unpin', false, 'Only the post owner can unpin comments'],
  ] as const)('%s validates comment, post, owner and update result', async (method, value, forbiddenMessage) => {
    await expect((setup().service as any)[method](ownerId, commentId)).rejects.toThrow('Comment not found');

    const comment = { postId: { toString: () => postId } };
    const noPost = setup({
      commentsRepository: { findById: jest.fn().mockResolvedValue(comment) },
      postsRepository: { findById: jest.fn().mockResolvedValue(null) },
    });
    await expect((noPost.service as any)[method](ownerId, commentId)).rejects.toThrow('Post not found');

    const forbidden = setup({ commentsRepository: { findById: jest.fn().mockResolvedValue(comment) } });
    await expect((forbidden.service as any)[method](userId, commentId)).rejects.toThrow(forbiddenMessage);

    const lost = setup({
      commentsRepository: {
        findById: jest.fn().mockResolvedValue(comment),
        setPinned: jest.fn().mockResolvedValue(null),
      },
    });
    await expect((lost.service as any)[method](ownerId, commentId)).rejects.toThrow('Comment not found');

    const success = setup({ commentsRepository: { findById: jest.fn().mockResolvedValue(comment) } });
    await expect((success.service as any)[method](ownerId, commentId)).resolves.toEqual({ id: commentId });
    expect(success.commentsRepository.setPinned).toHaveBeenCalledWith(commentId, value);
  });

  it('creates replies through the standard creation workflow', async () => {
    const ctx = setup({
      commentsRepository: {
        findById: jest.fn().mockResolvedValue({ postId: { toString: () => postId } }),
      },
    });
    const create = jest.spyOn(ctx.service, 'create').mockResolvedValue({ id: 'reply' } as any);

    await expect(
      ctx.service.createReply(userId, commentId, { content: 'reply', mentionUsernames: ['friend'] }),
    ).resolves.toEqual({ id: 'reply' });
    expect(create).toHaveBeenCalledWith(userId, {
      postId,
      content: 'reply',
      mentionUsernames: ['friend'],
      parentCommentId: commentId,
    });

    await expect(setup().service.createReply(userId, commentId, { content: 'reply' })).rejects.toThrow(
      'Parent comment not found',
    );
  });

  it('removes only the actor own comment and resynchronizes counters', async () => {
    const own = {
      authorId: { toString: () => userId },
      postId: { toString: () => postId },
    };
    const ctx = setup({ commentsRepository: { findById: jest.fn().mockResolvedValue(own) } });
    await expect(ctx.service.remove(userId, commentId)).resolves.toEqual({ success: true });
    expect(ctx.commentsRepository.deleteById).toHaveBeenCalledWith(commentId, userId);
    expect(ctx.postsRepository.setCommentsCount).toHaveBeenCalledWith(postId, 4);

    await expect(setup().service.remove(userId, commentId)).rejects.toThrow('Comment not found');
    const foreign = setup({
      commentsRepository: {
        findById: jest.fn().mockResolvedValue({ ...own, authorId: { toString: () => ownerId } }),
      },
    });
    await expect(foreign.service.remove(userId, commentId)).rejects.toThrow(
      'You can only delete your own comments',
    );
  });

  it('validates update ownership, payload and repository races', async () => {
    await expect(setup().service.update(userId, commentId, { content: 'new' })).rejects.toThrow(
      'Comment not found',
    );
    const base = {
      authorId: { toString: () => userId },
      postId: { toString: () => postId },
      content: 'old',
      mentionUsernames: [],
    };
    const foreign = setup({
      commentsRepository: {
        findById: jest.fn().mockResolvedValue({ ...base, authorId: { toString: () => ownerId } }),
      },
    });
    await expect(foreign.service.update(userId, commentId, { content: 'new' })).rejects.toThrow(
      'You can only update your own comments',
    );

    const ctx = setup({ commentsRepository: { findById: jest.fn().mockResolvedValue(base) } });
    await expect(ctx.service.update(userId, commentId, {})).rejects.toThrow('Nothing to update');
    await expect(ctx.service.update(userId, commentId, { content: '   ' })).rejects.toThrow(
      'Comment content cannot be empty',
    );
    await expect(ctx.service.update(userId, commentId, { content: 'valid' })).rejects.toThrow(
      'Comment not found',
    );
  });

  it('deletes comments as superadmin and records an audit trail', async () => {
    const comment = { postId: { toString: () => postId } };
    const ctx = setup({ commentsRepository: { findById: jest.fn().mockResolvedValue(comment) } });
    await expect(ctx.service.removeBySuperAdmin('admin@example.com', commentId)).resolves.toEqual({
      success: true,
      message: 'Comment deleted by superadmin',
    });
    expect(ctx.feedVersionService.bumpGlobalVersion).toHaveBeenCalled();
    expect(ctx.auditService.logSuperAdminAction).toHaveBeenCalledWith(
      'admin@example.com',
      'comment_delete',
      'comment',
      commentId,
      { postId },
    );

    await expect(setup().service.removeBySuperAdmin('admin', commentId)).rejects.toThrow(
      'Comment not found',
    );
  });

  it('lists top-level comments with reply previews and viewer capabilities', async () => {
    const replyId = '507f191e810c19729de860ec';
    const makeComment = (id: string, author: string, content: string, deleted = false) => ({
      id,
      authorId: { id: author, name: author === userId ? 'Me' : 'Owner' },
      content,
      isDeleted: deleted,
      toObject: () => ({
        id,
        authorId: { id: author, name: author === userId ? 'Me' : 'Owner' },
        content,
        isDeleted: deleted,
      }),
    });
    const root = makeComment(commentId, userId, 'root');
    const reply = makeComment(replyId, ownerId, 'reply', true);
    const ctx = setup({
      commentsRepository: {
        findManyTop: jest.fn().mockResolvedValue([root]),
        count: jest.fn().mockResolvedValue(1),
        countRepliesByParentIds: jest.fn().mockResolvedValue({ [commentId]: 3 }),
        countLikesByCommentIds: jest
          .fn()
          .mockResolvedValueOnce({ [commentId]: 5 })
          .mockResolvedValueOnce({ [replyId]: 2 }),
        findLikedCommentIds: jest
          .fn()
          .mockResolvedValueOnce([commentId])
          .mockResolvedValueOnce([replyId]),
        findReplyPreviewsByParentIds: jest.fn().mockResolvedValue({ [commentId]: [reply] }),
      },
    });

    const result = await ctx.service.findByPost(userId, postId, {
      page: 1,
      limit: 10,
      sortBy: CommentSortBy.TOP,
      sortOrder: SortOrder.ASC,
    });

    expect(ctx.commentsRepository.findManyTop).toHaveBeenCalledWith(
      expect.objectContaining({ postId: expect.any(Types.ObjectId) }),
      0,
      10,
      1,
    );
    expect(result.items[0]).toMatchObject({
      content: 'root',
      repliesCount: 3,
      likesCount: 5,
      likedByMe: true,
      canEdit: true,
      canDelete: true,
    });
    expect((result.items[0] as any).repliesPreview[0]).toMatchObject({
      content: 'This comment was deleted',
      canEdit: false,
      likesCount: 2,
      likedByMe: true,
      replyToUser: { id: userId, name: 'Me' },
    });
  });

  it('lists chronological comments and returns empty enrichment cheaply', async () => {
    const ctx = setup();
    const result = await ctx.service.findByPost(userId, postId, {
      page: 2,
      limit: 5,
      sortOrder: SortOrder.DESC,
    });
    expect(ctx.commentsRepository.findMany).toHaveBeenCalledWith(
      expect.objectContaining({ postId: expect.any(Types.ObjectId) }),
      5,
      5,
      { isPinned: -1, createdAt: -1 },
    );
    expect(result.items).toEqual([]);
    expect(ctx.commentsRepository.countRepliesByParentIds).not.toHaveBeenCalled();

    await expect(
      ctx.service.findByPost(userId, 'bad', { sortOrder: SortOrder.DESC }),
    ).rejects.toThrow('Invalid post id');
  });

  it('lists replies with parent author context using both sort modes', async () => {
    const reply = {
      id: '507f191e810c19729de860ec',
      authorId: ownerId,
      content: 'reply',
    };
    const parentAuthor = { id: ownerId, name: 'Parent', username: 'parent' };
    const ctx = setup({
      commentsRepository: {
        findMany: jest.fn().mockResolvedValue([reply]),
        count: jest.fn().mockResolvedValue(1),
        findByIdWithAuthor: jest.fn().mockResolvedValue({ authorId: parentAuthor }),
      },
    });
    const result = await ctx.service.findReplies(userId, commentId, {
      page: 1,
      limit: 20,
      sortOrder: SortOrder.DESC,
    });
    expect(result.items[0]).toMatchObject({
      content: 'reply',
      replyToUser: { id: ownerId, name: 'Parent', username: 'parent' },
    });

    const top = setup();
    await top.service.findReplies(userId, commentId, {
      sortBy: CommentSortBy.TOP,
      sortOrder: SortOrder.ASC,
    });
    expect(top.commentsRepository.findManyTop).toHaveBeenCalledWith(
      expect.objectContaining({ parentCommentId: expect.any(Types.ObjectId) }),
      0,
      20,
      1,
    );

    await expect(ctx.service.findReplies(userId, 'bad', { sortOrder: SortOrder.DESC })).rejects.toThrow(
      'Invalid parent comment id',
    );
  });

  it('builds escaped admin filters and paginates platform comments', async () => {
    const rows = [{ id: commentId }];
    const ctx = setup({
      commentsRepository: {
        findManyAdmin: jest.fn().mockResolvedValue(rows),
        countAdmin: jest.fn().mockResolvedValue(7),
      },
    });
    const result = await ctx.service.findPlatformComments({
      page: 2,
      limit: 3,
      sortOrder: SortOrder.ASC,
      postId,
      authorId: ownerId,
      q: '  a+b  ',
      moderationStatus: ModerationStatus.FLAGGED,
    });

    expect(result).toMatchObject({ items: rows, total: 7, page: 2, limit: 3 });
    expect(ctx.commentsRepository.findManyAdmin).toHaveBeenCalledWith(
      expect.objectContaining({
        postId: expect.any(Types.ObjectId),
        authorId: expect.any(Types.ObjectId),
        content: { $regex: 'a\\+b', $options: 'i' },
        moderationStatus: ModerationStatus.FLAGGED,
      }),
      3,
      3,
      { createdAt: 1 },
    );
  });

  it('updates comment moderation status and audits previous and next state', async () => {
    const original = { moderationStatus: ModerationStatus.FLAGGED };
    const updated = { id: commentId, moderationStatus: ModerationStatus.HIDDEN };
    const ctx = setup({
      commentsRepository: {
        findById: jest.fn().mockResolvedValue(original),
        updateModerationStatus: jest.fn().mockResolvedValue(updated),
      },
    });
    await expect(
      ctx.service.updateModerationStatusBySuperAdmin('admin', commentId, {
        status: ModerationStatus.HIDDEN,
        reason: '  abuse  ',
      }),
    ).resolves.toBe(updated);
    expect(ctx.commentsRepository.updateModerationStatus).toHaveBeenCalledWith(commentId, {
      moderationStatus: ModerationStatus.HIDDEN,
      moderationReason: 'abuse',
    });
    expect(ctx.auditService.logSuperAdminAction).toHaveBeenCalledWith(
      'admin',
      'comment_moderation_status_update',
      'comment',
      commentId,
      {
        previousStatus: ModerationStatus.FLAGGED,
        nextStatus: ModerationStatus.HIDDEN,
        reason: 'abuse',
      },
    );

    await expect(
      setup().service.updateModerationStatusBySuperAdmin('admin', commentId, {
        status: ModerationStatus.ACTIVE,
      }),
    ).rejects.toThrow('Comment not found');

    const lost = setup({
      commentsRepository: { findById: jest.fn().mockResolvedValue({ moderationStatus: undefined }) },
    });
    await expect(
      lost.service.updateModerationStatusBySuperAdmin('admin', commentId, {
        status: ModerationStatus.ACTIVE,
      }),
    ).rejects.toThrow('Comment not found');
  });

  it('isolates comment and mention notification delivery failures', async () => {
    const mentionedId = '507f191e810c19729de860ee';
    const ctx = setup({
      notificationsService: {
        createCommentNotification: jest.fn().mockRejectedValue('offline'),
        createMentionNotification: jest.fn().mockRejectedValue(new Error('offline')),
      },
      usersRepository: {
        findByUsernames: jest.fn().mockResolvedValue([{ id: mentionedId, username: 'friend' }]),
      },
    });

    await expect(ctx.service.create(userId, { postId, content: '@friend hello' })).resolves.toBeDefined();
    expect(ctx.notificationsService.createCommentNotification).toHaveBeenCalled();
    expect(ctx.notificationsService.createMentionNotification).toHaveBeenCalled();
  });
  it('updates own comment and notifies newly mentioned users', async () => {
    const userId = new Types.ObjectId().toString();
    const postId = new Types.ObjectId().toString();
    const commentId = new Types.ObjectId().toString();
    const updatedContent = 'Edited comment @new_mention';

    const commentsRepository = {
      findById: jest.fn().mockResolvedValue({
        _id: new Types.ObjectId(commentId),
        authorId: new Types.ObjectId(userId),
        postId: new Types.ObjectId(postId),
        content: 'Original comment',
        mentionUsernames: ['old_mention'],
      }),
      updateById: jest.fn().mockResolvedValue({
        _id: new Types.ObjectId(commentId),
        authorId: new Types.ObjectId(userId),
        postId: new Types.ObjectId(postId),
        content: updatedContent,
        mentionUsernames: ['new_mention'],
      }),
    };
    const postsRepository = {
      findById: jest.fn(),
      setCommentsCount: jest.fn(),
    };
    const auditService = {
      logSuperAdminAction: jest.fn(),
    };
    const feedVersionService = {
      bumpGlobalVersion: jest.fn(),
      bumpUserVersion: jest.fn(),
    };
    const notificationsService = {
      createCommentNotification: jest.fn(),
      createMentionNotification: jest.fn(),
    };
    const usersRepository = {
      findByUsernames: jest.fn().mockResolvedValue([{ id: 'mentioned-user-id', username: 'new_mention' }]),
    };
    const followsRepository = {
      findOne: jest.fn(),
    };
    const blocksRepository = {
      findAnyBetween: jest.fn().mockResolvedValue(null),
    };

    const service = new CommentsService(
      commentsRepository as any,
      postsRepository as any,
      auditService as any,
      feedVersionService as any,
      notificationsService as any,
      usersRepository as any,
      followsRepository as any,
      blocksRepository as any,
    );

    const result = await service.update(userId, commentId, {
      content: updatedContent,
      mentionUsernames: ['new_mention'],
    });

    expect(commentsRepository.updateById).toHaveBeenCalledWith(commentId, {
      content: updatedContent,
      mentionUsernames: ['new_mention'],
    });
    expect(feedVersionService.bumpUserVersion).toHaveBeenCalledWith(userId);
    expect(notificationsService.createMentionNotification).toHaveBeenCalledWith(
      userId,
      'mentioned-user-id',
      postId,
      {
        resourceType: 'comment',
        previewText: updatedContent,
        deepLink: `/posts/${postId}`,
      },
    );
    expect(result).toEqual(
      expect.objectContaining({
        content: updatedContent,
        mentionUsernames: ['new_mention'],
      }),
    );
  });

  it('prevents creating a comment when either user blocked the post owner', async () => {
    const userId = new Types.ObjectId().toString();
    const ownerId = new Types.ObjectId().toString();
    const postId = new Types.ObjectId().toString();
    const commentsRepository = {
      create: jest.fn(),
    };
    const postsRepository = {
      findById: jest.fn().mockResolvedValue({
        _id: new Types.ObjectId(postId),
        authorId: new Types.ObjectId(ownerId),
        commentsDisabled: false,
        commentsFollowersOnly: false,
      }),
    };
    const blocksRepository = {
      findAnyBetween: jest.fn().mockResolvedValue({ id: 'block-1' }),
    };

    const service = new CommentsService(
      commentsRepository as any,
      postsRepository as any,
      { logSuperAdminAction: jest.fn() } as any,
      { bumpGlobalVersion: jest.fn() } as any,
      { createCommentNotification: jest.fn(), createMentionNotification: jest.fn() } as any,
      { findByUsernames: jest.fn() } as any,
      { findOne: jest.fn() } as any,
      blocksRepository as any,
    );

    await expect(service.create(userId, { postId, content: 'hello' })).rejects.toThrow(
      'You cannot interact with this user',
    );
    expect(commentsRepository.create).not.toHaveBeenCalled();
  });
});
