import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
import type { AuthenticatedUser } from '../src/common/auth/authenticated-user';
import { noModuleAccess } from '../src/common/access/modules';
import { CommunityService } from '../src/modules/community/community.service';
import type { CommunityRepository } from '../src/modules/community/community.repository';
import type { CommunityPushService } from '../src/modules/community/community-push.service';

/**
 * Community & Research is the first module where two accounts share data, so
 * "who sees what" stops being a formality. These cover the three separations
 * that carry real consequence if they ever regress:
 *
 *   a reader must not see drafts, must not see admin-only channels,
 *   and must not be able to publish.
 */

function viewer(overrides: Partial<AuthenticatedUser> = {}): AuthenticatedUser {
  return {
    id: 'user-1',
    email: 'reader@example.test',
    displayName: 'Reader',
    role: 'USER',
    sessionId: 'session-1',
    modules: { ...noModuleAccess(), community: 'READ' },
    ...overrides,
  };
}

const editor = viewer({
  id: 'user-2',
  email: 'editor@example.test',
  modules: { ...noModuleAccess(), community: 'WRITE' },
});

const admin = viewer({
  id: 'user-3',
  email: 'admin@example.test',
  role: 'ADMIN',
  modules: { ...noModuleAccess(), community: 'WRITE' },
});

/**
 * The mocks are held as standalone functions rather than reached through the
 * repository object, so assertions never pull a method off its receiver.
 */
function harness(
  options: {
    channels?: unknown[];
    post?: unknown;
    channel?: unknown;
    announcement?: unknown;
  } = {},
) {
  const listChannels = vi.fn().mockResolvedValue(options.channels ?? []);
  const listPosts = vi.fn().mockResolvedValue([]);
  const listChats = vi.fn().mockResolvedValue([]);
  const settings = vi.fn().mockResolvedValue({});
  const postById = vi.fn().mockResolvedValue(options.post ?? null);
  const channelById = vi.fn().mockResolvedValue(options.channel ?? null);
  const incrementViews = vi.fn().mockResolvedValue(undefined);
  const commentsForPost = vi.fn().mockResolvedValue([]);
  const likeCount = vi.fn().mockResolvedValue(0);
  const hasLiked = vi.fn().mockResolvedValue(false);
  const listAnnouncements = vi.fn().mockResolvedValue([]);
  const activeMemberCount = vi.fn().mockResolvedValue(0);
  const announcementById = vi.fn().mockResolvedValue(options.announcement ?? null);
  const createAnnouncement = vi.fn().mockResolvedValue({ id: 'a1' });
  const createPost = vi.fn().mockImplementation((input: Record<string, unknown>) => Promise.resolve({
    id: 'post-new',
    ...input,
  }));
  const updatePost = vi.fn().mockImplementation((id: string, input: Record<string, unknown>) => Promise.resolve({
    ...(options.post as Record<string, unknown> | undefined),
    id,
    ...input,
  }));
  const notifyPostPublished = vi.fn().mockResolvedValue(undefined);

  const repository = {
    listChannels,
    listPosts,
    listChats,
    settings,
    postById,
    channelById,
    incrementViews,
    commentsForPost,
    likeCount,
    hasLiked,
    listAnnouncements,
    activeMemberCount,
    announcementById,
    createAnnouncement,
    createPost,
    updatePost,
  } as unknown as CommunityRepository;
  const push = {
    notifyPostPublished,
    configuration: vi.fn().mockReturnValue({ enabled: true, publicKey: 'public-key' }),
    save: vi.fn().mockResolvedValue(undefined),
    remove: vi.fn().mockResolvedValue(undefined),
  } as unknown as CommunityPushService;

  return {
    service: new CommunityService(repository, push),
    listChannels,
    listPosts,
    incrementViews,
    listAnnouncements,
    notifyPostPublished,
  };
}

describe('community access rules', () => {
  it('shows a reader only published posts, an editor also drafts', async () => {
    const { service, listPosts } = harness({
      channels: [{ id: 'ck-daily', visibility: 'MEMBERS' }],
    });

    await service.bootstrap(viewer());
    expect(listPosts.mock.calls[0][0].statuses).toEqual(['PUBLISHED']);

    await service.bootstrap(editor);
    expect(listPosts.mock.calls[1][0].statuses).toEqual(['DRAFT', 'PUBLISHED']);
  });

  it('hides admin-only channels from everyone but administrators', async () => {
    const { service, listChannels } = harness();

    await service.bootstrap(editor);
    expect(listChannels.mock.calls[0][0]).toEqual(['MEMBERS']);

    await service.bootstrap(admin);
    expect(listChannels.mock.calls[1][0]).toEqual(['MEMBERS', 'ADMINS']);
  });

  it('refuses a reader who tries to publish', async () => {
    const { service } = harness();
    await expect(
      service.createPost(viewer(), { channelId: 'ck-daily', title: 'Nope' }),
    ).rejects.toBeInstanceOf(ForbiddenException);
  });

  it('answers "not found" for a draft a reader may not see, never "forbidden"', async () => {
    // Leaking the difference would tell a reader that unpublished work exists.
    const { service, incrementViews } = harness({
      post: { id: 'p1', channelId: 'ck-daily', status: 'DRAFT' },
      channel: { id: 'ck-daily', visibility: 'MEMBERS' },
    });

    await expect(service.post(viewer(), 'p1')).rejects.toBeInstanceOf(NotFoundException);
    expect(incrementViews).not.toHaveBeenCalled();
  });

  it('shows a reader published announcements only, an editor also drafts', async () => {
    // Announcements reach every member whatever channels they hold, so the only
    // thing that may hide one is it not being published yet.
    const { service, listAnnouncements } = harness();

    await service.bootstrap(viewer());
    expect(listAnnouncements.mock.calls[0][0].statuses).toEqual(['PUBLISHED']);

    await service.bootstrap(editor);
    expect(listAnnouncements.mock.calls[1][0].statuses).toEqual(['DRAFT', 'PUBLISHED']);
  });

  it('refuses a reader who tries to announce something to everyone', async () => {
    const { service } = harness();
    await expect(
      service.createAnnouncement(viewer(), { title: 'Nope' }),
    ).rejects.toBeInstanceOf(ForbiddenException);
  });

  it('keeps portal settings for administrators only', async () => {
    const { service } = harness();
    await expect(service.updateSettings(editor, { org: 'x' })).rejects.toBeInstanceOf(
      ForbiddenException,
    );
  });

  it('notifies eligible devices when a post is first published', async () => {
    const { service, notifyPostPublished } = harness({
      channel: { id: 'ck-daily', name: 'CK Daily', visibility: 'MEMBERS' },
    });

    await service.createPost(editor, {
      channelId: 'ck-daily',
      title: 'Neue Analyse',
      excerpt: 'Das Wichtigste in Kürze.',
      status: 'PUBLISHED',
    });

    expect(notifyPostPublished).toHaveBeenCalledOnce();
    expect(notifyPostPublished).toHaveBeenCalledWith(
      expect.objectContaining({
        id: 'post-new',
        title: 'Neue Analyse',
        channelName: 'CK Daily',
        visibility: 'MEMBERS',
      }),
    );
  });

  it('does not send another push when an already published post is edited', async () => {
    const { service, notifyPostPublished } = harness({
      post: {
        id: 'post-live',
        channelId: 'ck-daily',
        title: 'Bestehende Analyse',
        excerpt: null,
        status: 'PUBLISHED',
        publishedAt: new Date('2026-08-01T10:00:00Z'),
      },
      channel: { id: 'ck-daily', name: 'CK Daily', visibility: 'MEMBERS' },
    });

    await service.updatePost(editor, 'post-live', { title: 'Korrigierter Titel' });

    expect(notifyPostPublished).not.toHaveBeenCalled();
  });
});
