import { Types } from 'mongoose';
import { PostType } from '../../common/enums/post-type.enum';
import { PostVisibility } from '../../common/enums/post-visibility.enum';
import { decodeOffsetCursor } from '../../common/utils/cursor.util';
import { buildPostMediaResponse } from '../../common/utils/post-media-response.util';
import { FeedService } from './feed.service';

const makePost = (input: Partial<Record<string, any>> = {}) => {
  const id = input.id ?? new Types.ObjectId().toString();
  const authorId = input.authorId ?? new Types.ObjectId().toString();
  const postType = input.postType ?? PostType.TEXT;
  const raw = {
    _id: new Types.ObjectId(id),
    id,
    authorId: {
      _id: new Types.ObjectId(authorId),
      id: authorId,
      username: `author_${authorId.slice(-4)}`,
      isVerified: input.authorIsVerified ?? false,
      isDisabled: input.authorIsDisabled ?? false,
      followersCount: input.authorFollowersCount ?? 0,
    },
    content: input.content ?? `${postType} content`,
    postType,
    visibility: input.visibility ?? PostVisibility.PUBLIC,
    videoUrl: input.videoUrl ?? '',
    hlsUrl: input.hlsUrl ?? '',
    thumbnailUrl: input.thumbnailUrl ?? '',
    likesCount: input.likesCount ?? 0,
    commentsCount: input.commentsCount ?? 0,
    savesCount: input.savesCount ?? 0,
    shareCount: input.shareCount ?? 0,
    viewCount: input.viewCount ?? 0,
    playCount: input.playCount ?? 0,
    hashtags: input.hashtags ?? [],
    commentsDisabled: input.commentsDisabled ?? false,
    createdAt: input.createdAt ?? new Date(),
  };

  return {
    ...raw,
    toObject: () => ({
      ...raw,
      media: buildPostMediaResponse(raw),
    }),
  };
};

const createService = () => {
  const feedRepository = {
    findFollowingIds: jest.fn().mockResolvedValue([]),
    findCandidatePosts: jest.fn().mockResolvedValue([]),
    findPopularCandidatePosts: jest.fn().mockResolvedValue([]),
    findTrendingPublicPosts: jest.fn().mockResolvedValue([]),
    count: jest.fn().mockResolvedValue(0),
  };
  const usersRepository = {
    findById: jest.fn().mockResolvedValue({
      id: new Types.ObjectId().toString(),
      musicGenres: [],
      favoriteInstruments: [],
      favoriteMaqamat: [],
      musicRoles: [],
    }),
    findPrivateUserIds: jest.fn().mockResolvedValue([]),
  };
  const cacheService = {
    get: jest.fn(),
    set: jest.fn(),
    acquireFillLock: jest.fn().mockResolvedValue({ acquired: true, release: jest.fn().mockResolvedValue(true) }),
    waitForValue: jest.fn(),
  };
  const configService = {
    get: jest.fn((key: string): unknown => {
      if (key === 'feedCache.enabled') {
        return false;
      }
      return undefined;
    }),
  };
  const likesRepository = {
    findLikedPostIds: jest.fn().mockResolvedValue([]),
  };
  const savesRepository = {
    findSavedPostIds: jest.fn().mockResolvedValue([]),
  };
  const emptyCursor = {
    sort: jest.fn().mockReturnThis(),
    limit: jest.fn().mockReturnThis(),
    project: jest.fn().mockReturnThis(),
    toArray: jest.fn().mockResolvedValue([]),
  };
  const connection = {
    collection: jest.fn(() => ({
      find: jest.fn(() => emptyCursor),
      aggregate: jest.fn(() => ({ toArray: jest.fn().mockResolvedValue([]) })),
    })),
  };
  const feedVersionService = {
    getGlobalVersion: jest.fn().mockResolvedValue(1),
    getUserVersion: jest.fn().mockResolvedValue(1),
    bumpGlobalVersion: jest.fn(),
  };
  const followsService = { getSuggestions: jest.fn().mockResolvedValue({ items: [] }) };
  const marketplaceService = {
    getPublicListings: jest.fn().mockResolvedValue({ items: [] }),
    getPublicInstruments: jest.fn().mockResolvedValue({ items: [] }),
    getPublicRepairShops: jest.fn().mockResolvedValue({ items: [] }),
  };
  const blocksService = { getInvisibleUserIds: jest.fn().mockResolvedValue([]) };

  const service = new FeedService(
    feedRepository as any,
    usersRepository as any,
    cacheService as any,
    feedVersionService as any,
    configService as any,
    likesRepository as any,
    savesRepository as any,
    followsService as any,
    marketplaceService as any,
    blocksService as any,
    connection as any,
  );

  return {
    service,
    feedRepository,
    usersRepository,
    cacheService,
    configService,
    feedVersionService,
    likesRepository,
    savesRepository,
    followsService,
    marketplaceService,
    blocksService,
    connection,
  };
};

describe('FeedService reels feed', () => {
  it('keeps /feed/me without preferredPostType unfiltered', async () => {
    const { service, feedRepository } = createService();
    const posts = [
      makePost({ postType: PostType.IMAGE }),
      makePost({ postType: PostType.VIDEO, videoUrl: 'https://cdn.example.com/video.mp4' }),
    ];
    feedRepository.findCandidatePosts.mockResolvedValue(posts);

    const result = (await service.getMyFeed(new Types.ObjectId().toString(), {
      includeSuggestions: false,
      limit: 10,
    })) as any;

    expect(result.items).toHaveLength(2);
    expect(result.items.map((item: any) => item.postType).sort()).toEqual(['image', 'video']);
    expect(result.pagination.mode).toBe('cursor');
  });

  it('maps preferredPostType=reel to video posts and normalizes response postType', async () => {
    const { service, feedRepository, likesRepository, savesRepository } = createService();
    const reel = makePost({
      postType: PostType.VIDEO,
      videoUrl: 'https://cdn.example.com/reel.mp4',
      hlsUrl: 'https://cdn.example.com/reel.m3u8',
      thumbnailUrl: 'https://cdn.example.com/reel.jpg',
      likesCount: 2,
      commentsCount: 3,
      savesCount: 4,
      shareCount: 5,
      viewCount: 6,
      playCount: 7,
      commentsDisabled: true,
    });
    const image = makePost({ postType: PostType.IMAGE });
    feedRepository.findCandidatePosts.mockResolvedValue([reel, image]);
    likesRepository.findLikedPostIds.mockResolvedValue([reel.id]);
    savesRepository.findSavedPostIds.mockResolvedValue([reel.id]);

    const result = (await service.getMyFeed(new Types.ObjectId().toString(), {
      preferredPostType: 'reel',
      includeSuggestions: false,
      limit: 10,
    })) as any;

    const candidateFilter = feedRepository.findCandidatePosts.mock.calls[0][0];
    expect(JSON.stringify(candidateFilter)).toContain(`"postType":"${PostType.VIDEO}"`);
    expect(JSON.stringify(candidateFilter)).toContain('"createdAt"');
    expect(result.items).toHaveLength(1);
    expect(result.items[0]).toEqual(
      expect.objectContaining({
        id: reel.id,
        postType: 'reel',
        feedItemType: 'post',
        likedByMe: true,
        savedByMe: true,
        isSharedByMe: false,
        commentsDisabled: true,
      }),
    );
    expect((result.items[0] as any).media).toEqual(
      expect.objectContaining({
        preferredPlaybackUrl: 'https://cdn.example.com/reel.m3u8',
        hlsUrl: 'https://cdn.example.com/reel.m3u8',
        videoUrl: 'https://cdn.example.com/reel.mp4',
        thumbnailUrl: 'https://cdn.example.com/reel.jpg',
      }),
    );
    expect((result.items[0] as any).engagement).toEqual(
      expect.objectContaining({
        likesCount: 2,
        commentsCount: 3,
        savesCount: 4,
        shareCount: 5,
        viewCount: 6,
        playCount: 7,
      }),
    );
  });

  it('uses cursor pagination for reels', async () => {
    const { service, feedRepository } = createService();
    feedRepository.findCandidatePosts.mockResolvedValue([
      makePost({ postType: PostType.VIDEO, createdAt: new Date('2026-07-06T10:02:00.000Z') }),
      makePost({ postType: PostType.VIDEO, createdAt: new Date('2026-07-06T10:01:00.000Z') }),
      makePost({ postType: PostType.VIDEO, createdAt: new Date('2026-07-06T10:00:00.000Z') }),
    ]);

    const firstPage = (await service.getMyFeed(new Types.ObjectId().toString(), {
      preferredPostType: 'reel',
      includeSuggestions: false,
      limit: 2,
    })) as any;
    const secondPage = (await service.getMyFeed(new Types.ObjectId().toString(), {
      preferredPostType: 'reel',
      includeSuggestions: false,
      limit: 2,
      cursor: firstPage.nextCursor ?? undefined,
    })) as any;

    expect(firstPage.items).toHaveLength(2);
    expect(firstPage.nextCursor).toBeTruthy();
    expect(decodeOffsetCursor(firstPage.nextCursor ?? undefined)).toBe(2);
    expect(secondPage.items).toHaveLength(1);
    expect(secondPage.nextCursor).toBeNull();
  });

  it('exposes /feed/reels through the same reel contract', async () => {
    const { service, feedRepository } = createService();
    feedRepository.findCandidatePosts.mockResolvedValue([
      makePost({ postType: PostType.VIDEO, videoUrl: 'https://cdn.example.com/reel.mp4' }),
    ]);

    const result = (await service.getReels(new Types.ObjectId().toString(), { limit: 10 })) as any;

    expect(result.items).toHaveLength(1);
    expect(result.items[0]).toEqual(expect.objectContaining({ postType: 'reel' }));
  });

  it('never returns posts from disabled authors', async () => {
    const { service, feedRepository } = createService();
    const visible = makePost({ authorIsDisabled: false });
    const disabled = makePost({ authorIsDisabled: true, likesCount: 100_000 });
    feedRepository.findCandidatePosts.mockResolvedValue([disabled, visible]);

    const result = (await service.getMyFeed(new Types.ObjectId().toString(), {
      includeSuggestions: false,
      limit: 10,
    })) as any;

    expect(result.items.map((item: any) => item.id)).toEqual([visible.id]);
  });

  it('uses recent viewer interactions to personalize ranking', async () => {
    const { service, feedRepository } = createService();
    const preferredAuthorId = new Types.ObjectId().toString();
    const otherAuthorId = new Types.ObjectId().toString();
    const preferred = makePost({ authorId: preferredAuthorId });
    const other = makePost({ authorId: otherAuthorId });
    feedRepository.findCandidatePosts.mockResolvedValue([other, preferred]);
    jest.spyOn(service as any, 'getViewerRankingProfile').mockResolvedValue({
      authorAffinity: { [preferredAuthorId]: 20 },
      postTypeAffinity: {},
      hashtagAffinity: {},
      interactedPostIds: [],
    });

    const result = (await service.getMyFeed(new Types.ObjectId().toString(), {
      includeSuggestions: false,
      limit: 10,
    })) as any;

    expect(result.items[0].id).toBe(preferred.id);
  });

  it('diversifies consecutive authors even when one author has several strong posts', async () => {
    const { service, feedRepository } = createService();
    const repeatedAuthorId = new Types.ObjectId().toString();
    const alternateAuthorId = new Types.ObjectId().toString();
    const posts = [
      makePost({ authorId: repeatedAuthorId, likesCount: 20 }),
      makePost({ authorId: repeatedAuthorId, likesCount: 18 }),
      makePost({ authorId: alternateAuthorId, likesCount: 10 }),
    ];
    feedRepository.findCandidatePosts.mockResolvedValue(posts);

    const result = (await service.getMyFeed(new Types.ObjectId().toString(), {
      includeSuggestions: false,
      limit: 10,
    })) as any;

    const firstAuthor = result.items[0].authorId._id.toString();
    const secondAuthor = result.items[1].authorId._id.toString();
    expect(firstAuthor).not.toBe(secondAuthor);
  });

  it('hard-filters posts already reported by the viewer', async () => {
    const { service, feedRepository } = createService();
    const reportedId = new Types.ObjectId();
    feedRepository.findCandidatePosts.mockResolvedValue([]);
    jest.spyOn(service as any, 'findReportedPostIds').mockResolvedValue([reportedId]);

    await service.getMyFeed(new Types.ObjectId().toString(), {
      includeSuggestions: false,
      followingOnly: false,
      limit: 10,
    });

    const candidateFilter = feedRepository.findCandidatePosts.mock.calls[0][0];
    expect(JSON.stringify(candidateFilter)).toContain(reportedId.toString());
  });

  it('encodes a stable ranking timestamp in the opaque cursor', async () => {
    const { service, feedRepository } = createService();
    feedRepository.findCandidatePosts.mockResolvedValue([
      makePost({ createdAt: new Date('2026-07-06T10:02:00.000Z') }),
      makePost({ createdAt: new Date('2026-07-06T10:01:00.000Z') }),
    ]);

    const firstPage = (await service.getMyFeed(new Types.ObjectId().toString(), {
      includeSuggestions: false,
      limit: 1,
    })) as any;
    const payload = JSON.parse(Buffer.from(firstPage.nextCursor, 'base64url').toString('utf8'));

    expect(payload).toMatchObject({ version: 1, offset: 1 });
    expect(new Date(payload.rankedAt).toString()).not.toBe('Invalid Date');
    expect(decodeOffsetCursor(firstPage.nextCursor)).toBe(1);
  });

  it('rejects a feed request when the current user no longer exists', async () => {
    const { service, usersRepository, feedRepository } = createService();
    usersRepository.findById.mockResolvedValue(null);

    await expect(
      service.getMyFeed(new Types.ObjectId().toString(), { includeSuggestions: false }),
    ).rejects.toThrow('Current user not found');
    expect(feedRepository.findCandidatePosts).not.toHaveBeenCalled();
  });

  it('falls back from an empty following feed to public discovery for default requests', async () => {
    const { service, feedRepository } = createService();
    const discovered = makePost();
    feedRepository.findCandidatePosts
      .mockResolvedValueOnce([])
      .mockResolvedValueOnce([discovered]);

    const result = (await service.getMyFeed(new Types.ObjectId().toString(), {
      includeSuggestions: false,
      limit: 10,
    })) as any;

    expect(feedRepository.findCandidatePosts).toHaveBeenCalledTimes(2);
    expect(result.items.map((item: any) => item.id)).toEqual([discovered.id]);

    const explicit = createService();
    explicit.feedRepository.findCandidatePosts.mockResolvedValue([]);
    await explicit.service.getMyFeed(new Types.ObjectId().toString(), {
      followingOnly: true,
      includeSuggestions: false,
    });
    expect(explicit.feedRepository.findCandidatePosts).toHaveBeenCalledTimes(1);
  });

  it('returns cached home feeds and coalesces concurrent cache fills', async () => {
    const cached = { items: [{ id: 'cached' }], nextCursor: null };
    const first = createService();
    first.configService.get.mockImplementation((key: string) => key === 'feedCache.enabled');
    first.cacheService.get.mockResolvedValue(cached);
    await expect(first.service.getMyFeed(new Types.ObjectId().toString(), {})).resolves.toBe(cached);
    expect(first.feedRepository.findCandidatePosts).not.toHaveBeenCalled();

    const coalesced = createService();
    coalesced.configService.get.mockImplementation((key: string) => key === 'feedCache.enabled');
    coalesced.cacheService.get.mockResolvedValue(null);
    coalesced.cacheService.acquireFillLock.mockResolvedValue({ acquired: false });
    coalesced.cacheService.waitForValue.mockResolvedValue(cached);
    await expect(coalesced.service.getMyFeed(new Types.ObjectId().toString(), {})).resolves.toBe(cached);
    expect(coalesced.cacheService.waitForValue).toHaveBeenCalledWith(expect.stringContaining('feed:me:'), 2000);
  });

  it('stores freshly ranked home feeds and releases the cache fill lock', async () => {
    const ctx = createService();
    const release = jest.fn().mockResolvedValue(true);
    ctx.configService.get.mockImplementation((key: string) => {
      if (key === 'feedCache.enabled') return true;
      if (key === 'feedCache.userFeedTtlSeconds') return 45;
      return false;
    });
    ctx.cacheService.get.mockResolvedValue(null);
    ctx.cacheService.acquireFillLock.mockResolvedValue({ acquired: true, release });
    ctx.feedRepository.findCandidatePosts.mockResolvedValue([makePost()]);

    const result = await ctx.service.getMyFeed(new Types.ObjectId().toString(), {
      includeSuggestions: false,
    });
    expect(ctx.cacheService.set).toHaveBeenCalledWith(expect.stringContaining('feed:me:'), result, 45);
    expect(release).toHaveBeenCalled();
  });

  it('mixes creator and marketplace cards into only the first requested home page', async () => {
    const ctx = createService();
    ctx.feedRepository.findCandidatePosts.mockResolvedValue([
      makePost(),
      makePost(),
      makePost(),
      makePost(),
    ]);
    ctx.followsService.getSuggestions.mockResolvedValue({ items: [{ user: { id: 'creator' } }] });
    ctx.marketplaceService.getPublicListings.mockResolvedValue({ items: [{ id: 'listing' }] });
    ctx.marketplaceService.getPublicInstruments.mockResolvedValue({ items: [{ id: 'instrument' }] });
    ctx.marketplaceService.getPublicRepairShops.mockResolvedValue({ items: [{ id: 'shop' }] });

    const result = (await ctx.service.getMyFeed(new Types.ObjectId().toString(), {
      includeSuggestions: true,
      suggestionInterval: 2,
      limit: 10,
    })) as any;
    expect(result.items.map((item: any) => item.feedItemType)).toEqual([
      'post',
      'post',
      'suggested_users',
      'post',
      'post',
      'featured_marketplace',
    ]);
    expect(result.items[2].items[0]).toMatchObject({ following: false });
    expect(result.items[5]).toMatchObject({
      listings: [{ id: 'listing' }],
      instruments: [{ id: 'instrument' }],
      repairShops: [{ id: 'shop' }],
    });

    const later = createService();
    later.feedRepository.findCandidatePosts.mockResolvedValue([makePost()]);
    await later.service.getMyFeed(new Types.ObjectId().toString(), {
      includeSuggestions: true,
      page: 2,
    });
    expect(later.followsService.getSuggestions).not.toHaveBeenCalled();
  });

  it('builds a cursor-based trending feed with visibility, block and reel filters', async () => {
    const ctx = createService();
    const invisibleId = new Types.ObjectId().toString();
    const visible = makePost({
      postType: PostType.VIDEO,
      videoUrl: 'https://cdn/reel.mp4',
    });
    const disabled = makePost({ postType: PostType.VIDEO, authorIsDisabled: true });
    ctx.blocksService.getInvisibleUserIds.mockResolvedValue([invisibleId]);
    ctx.feedRepository.findFollowingIds.mockResolvedValue([new Types.ObjectId().toString()]);
    ctx.feedRepository.findTrendingPublicPosts.mockResolvedValue([visible, disabled]);
    ctx.feedRepository.count.mockResolvedValue(3);
    ctx.likesRepository.findLikedPostIds.mockResolvedValue([visible.id]);

    const result = (await ctx.service.getTrending(new Types.ObjectId().toString(), {
      preferredPostType: 'reel',
      page: 1,
      limit: 2,
    })) as any;

    expect(ctx.feedRepository.findTrendingPublicPosts).toHaveBeenCalledWith(
      {
        visibility: PostVisibility.PUBLIC,
        authorId: { $nin: [new Types.ObjectId(invisibleId)] },
        postType: PostType.VIDEO,
      },
      0,
      2,
    );
    expect(result.items).toHaveLength(1);
    expect(result.items[0]).toMatchObject({ postType: 'reel', likedByMe: true });
    expect(result.nextCursor).toBeTruthy();
  });

  it('excludes private authors from trending and explore queries', async () => {
    const ctx = createService();
    const privateAuthorId = new Types.ObjectId().toString();
    ctx.usersRepository.findPrivateUserIds.mockResolvedValue([privateAuthorId]);

    await ctx.service.getTrending(new Types.ObjectId().toString(), { limit: 10 });

    expect(ctx.feedRepository.findTrendingPublicPosts).toHaveBeenCalledWith(
      expect.objectContaining({
        authorId: { $nin: [new Types.ObjectId(privateAuthorId)] },
      }),
      0,
      10,
    );
  });

  it('allows approved private follows in home feed but excludes pending private authors', async () => {
    const privateAuthorId = new Types.ObjectId().toString();
    const viewerId = new Types.ObjectId().toString();
    const approved = createService();
    approved.feedRepository.findFollowingIds.mockResolvedValue([privateAuthorId]);
    approved.usersRepository.findPrivateUserIds.mockResolvedValue([]);
    await approved.service.getMyFeed(viewerId, {
      followingOnly: false,
      includeSuggestions: false,
    });
    const approvedFilter = approved.feedRepository.findCandidatePosts.mock.calls[0][0];
    expect(JSON.stringify(approvedFilter)).toContain(privateAuthorId);

    const pending = createService();
    pending.feedRepository.findFollowingIds.mockResolvedValue([]);
    pending.usersRepository.findPrivateUserIds.mockResolvedValue([privateAuthorId]);
    await pending.service.getMyFeed(viewerId, {
      followingOnly: false,
      includeSuggestions: false,
    });
    const pendingFilter = pending.feedRepository.findCandidatePosts.mock.calls[0][0];
    expect(JSON.stringify(pendingFilter)).toContain(privateAuthorId);
    expect(JSON.stringify(pendingFilter)).toContain('$nin');
  });

  it('makes explore a public trending request and disables suggestion cards', async () => {
    const { service } = createService();
    const trending = jest.spyOn(service, 'getTrending').mockResolvedValue({ items: [] } as any);
    await service.getExplore(new Types.ObjectId().toString(), {
      followingOnly: true,
      includeSuggestions: true,
    });
    expect(trending).toHaveBeenCalledWith(
      expect.any(String),
      expect.objectContaining({ followingOnly: false, includeSuggestions: false }),
    );
  });

  it('returns cached and coalesced trending feeds and caches fresh results', async () => {
    const cached = { items: [], nextCursor: null };
    const hit = createService();
    hit.configService.get.mockImplementation((key: string) => key === 'feedCache.enabled');
    hit.cacheService.get.mockResolvedValue(cached);
    await expect(hit.service.getTrending(new Types.ObjectId().toString(), {})).resolves.toBe(cached);

    const coalesced = createService();
    coalesced.configService.get.mockImplementation((key: string) => key === 'feedCache.enabled');
    coalesced.cacheService.get.mockResolvedValue(null);
    coalesced.cacheService.acquireFillLock.mockResolvedValue({ acquired: false });
    coalesced.cacheService.waitForValue.mockResolvedValue(cached);
    await expect(coalesced.service.getTrending(new Types.ObjectId().toString(), {})).resolves.toBe(cached);

    const fresh = createService();
    const release = jest.fn().mockResolvedValue(true);
    fresh.configService.get.mockImplementation((key: string) => {
      if (key === 'feedCache.enabled') return true;
      if (key === 'feedCache.trendingTtlSeconds') return 12;
      return false;
    });
    fresh.cacheService.get.mockResolvedValue(null);
    fresh.cacheService.acquireFillLock.mockResolvedValue({ acquired: true, release });
    await fresh.service.getTrending(new Types.ObjectId().toString(), {});
    expect(fresh.cacheService.set).toHaveBeenCalledWith(
      expect.stringContaining('feed:trending:'),
      expect.any(Object),
      12,
    );
    expect(release).toHaveBeenCalled();
  });

  it('weights watch quality, proximity and preferences in ranking', () => {
    const { service } = createService();
    const viewerId = new Types.ObjectId().toString();
    const authorId = new Types.ObjectId().toString();
    const base = {
      currentUser: {
        latitude: 24.7136,
        longitude: 46.6753,
        musicGenres: ['Oud'],
        favoriteInstruments: [],
        favoriteMaqamat: [],
        musicRoles: [],
      },
      currentUserId: viewerId,
      followingIds: [authorId],
      post: makePost({ authorId, hashtags: ['oud'], postType: PostType.VIDEO }),
      preferredPostType: 'reel',
      radiusKm: 30,
      rankedAt: new Date(),
    };
    (base.post as any).authorId.latitude = 24.72;
    (base.post as any).authorId.longitude = 46.68;
    const neutral = (service as any).scorePost({
      ...base,
      rankingProfile: {
        authorAffinity: {},
        postTypeAffinity: {},
        hashtagAffinity: {},
        interactedPostIds: [],
        engagementSignals: {},
      },
    });
    const personalized = (service as any).scorePost({
      ...base,
      rankingProfile: {
        authorAffinity: { [authorId]: 10 },
        postTypeAffinity: { video: 8 },
        hashtagAffinity: { oud: 6 },
        interactedPostIds: [],
        engagementSignals: {
          [base.post.id]: { watchMs: 60_000, completions: 2, rewatches: 1, profileOpens: 1 },
        },
      },
    });
    expect(personalized).toBeGreaterThan(neutral);
    expect((service as any).computeDistanceKm(null, 0, 0, 0)).toBeNull();
    expect((service as any).computeDistanceKm(24.7136, 46.6753, 24.7136, 46.6753)).toBe(0);
  });

  it('falls back safely for malformed opaque feed cursors', async () => {
    const { service, feedRepository } = createService();
    feedRepository.findCandidatePosts.mockResolvedValue([makePost()]);
    await expect(
      service.getMyFeed(new Types.ObjectId().toString(), {
        cursor: 'not-a-valid-cursor',
        includeSuggestions: false,
      }),
    ).resolves.toBeDefined();
    expect(feedRepository.findCandidatePosts).toHaveBeenCalled();
  });
});
