import { NotFoundException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
import type { ConfigService } from '@nestjs/config';
import * as webPush from 'web-push';
import { noModuleAccess } from '../src/common/access/modules';
import {
  effectiveTier,
  NO_SUBSCRIPTION_FALLBACK_TIER,
  tierSatisfies,
} from '../src/common/access/subscription';
import type { AuthenticatedUser } from '../src/common/auth/authenticated-user';
import { CommunityPushService, notifiedTier } from '../src/modules/community/community-push.service';
import type { CommunityPushRepository } from '../src/modules/community/community-push.repository';
import type { CommunityRepository } from '../src/modules/community/community.repository';
import { CommunityService } from '../src/modules/community/community.service';

/**
 * Basis / Pro: who receives which content. The point of these tests is the
 * server-side filter — a Basis account must never be handed the body of a Pro
 * edition or of a channel with min_tier = PRO, whatever the browser does.
 */

vi.mock('web-push', () => ({ setVapidDetails: vi.fn(), sendNotification: vi.fn().mockResolvedValue({}) }));

const SECRET = 'GEHEIMER-PRO-INHALT';

const CHANNELS = [
  { id: 'ck-daily', name: 'CK Daily', visibility: 'MEMBERS', minTier: null, position: 0 },
  { id: 'ck-macro', name: 'CK Macro', visibility: 'MEMBERS', minTier: 'PRO', position: 1 },
  { id: 'ck-charts', name: 'CK Charts & Education', visibility: 'MEMBERS', minTier: 'PRO', position: 2 },
  { id: 'ck-trading-info', name: 'CK Trading Info', visibility: 'MEMBERS', minTier: null, position: 3 },
];

function row(id: string, channelId: string, variant: 'PRO' | 'BASIS', extra: object = {}) {
  return {
    id,
    channelId,
    status: 'PUBLISHED' as const,
    variant,
    title: `Titel ${id}`,
    excerpt: `Teaser ${id}`,
    cover: 'data:image/png;base64,AAAA',
    authorName: 'CK Team',
    legacyDate: null,
    publishedAt: new Date('2026-10-06T12:00:00Z'),
    views: 7,
    likes: 2,
    comments: 1,
    likedByMe: false,
    readByMe: false,
    pairedPostId: null as string | null,
    body: `<html>${SECRET} ${id}</html>`,
    ...extra,
  };
}

const POSTS = [
  row('daily-pro', 'ck-daily', 'PRO', { pairedPostId: 'daily-basis' }),
  row('daily-basis', 'ck-daily', 'BASIS', { pairedPostId: 'daily-pro' }),
  row('macro-1', 'ck-macro', 'PRO'),
  row('charts-1', 'ck-charts', 'PRO'),
  row('info-1', 'ck-trading-info', 'BASIS'),
];

function harness() {
  const { body: _body, ...summaryShape } = POSTS[0];
  void _body;
  void summaryShape;
  const listChannels = vi.fn().mockResolvedValue(CHANNELS);
  // The real listPosts never selects `body` or the cover image itself — only a
  // cache key for the cover. The fake mirrors that.
  const listPosts = vi.fn().mockResolvedValue(
    POSTS.map(({ body: _b, cover, ...summary }) => (void _b, { ...summary, coverVersion: cover ? '1' : null })),
  );
  const postById = vi.fn((id: string) => Promise.resolve(POSTS.find((post) => post.id === id) ?? null));
  const channelById = vi.fn((id: string) => Promise.resolve(CHANNELS.find((channel) => channel.id === id) ?? null));
  const incrementViews = vi.fn().mockResolvedValue(undefined);
  const markPostRead = vi.fn().mockResolvedValue(undefined);
  const unreadCounts = vi.fn().mockResolvedValue({ posts: 0, announcements: 0 });
  const setLike = vi.fn().mockResolvedValue(undefined);
  const createComment = vi.fn().mockResolvedValue({ id: 'c1' });
  const postCover = vi.fn((id: string) => {
    const post = POSTS.find((candidate) => candidate.id === id);
    return Promise.resolve(post ? { id: post.id, channelId: post.channelId, status: post.status, variant: post.variant, cover: post.cover } : null);
  });
  const repository = {
    listChannels,
    listPosts,
    postCover,
    listChats: vi.fn().mockResolvedValue([]),
    settings: vi.fn().mockResolvedValue({}),
    listAnnouncements: vi.fn().mockResolvedValue([]),
    activeMemberCount: vi.fn().mockResolvedValue(0),
    postById,
    channelById,
    incrementViews,
    markPostRead,
    commentsForPost: vi.fn().mockResolvedValue([]),
    likeCount: vi.fn().mockResolvedValue(0),
    hasLiked: vi.fn().mockResolvedValue(false),
    unreadCounts,
    setLike,
    likeCountAfter: vi.fn(),
    createComment,
  } as unknown as CommunityRepository;
  const push = { notifyPostPublished: vi.fn() } as unknown as CommunityPushService;
  return {
    service: new CommunityService(repository, push),
    incrementViews,
    markPostRead,
    unreadCounts,
    setLike,
    createComment,
  };
}

function account(overrides: Partial<AuthenticatedUser> = {}): AuthenticatedUser {
  return {
    id: 'u',
    email: 'u@example.test',
    displayName: 'U',
    role: 'USER',
    subscriptionTier: null,
    isReadOnly: false,
    accessExpiresAt: null,
    sessionId: 's',
    modules: { ...noModuleAccess(), community: 'READ' },
    ...overrides,
  };
}

const basis = account({ id: 'basis', subscriptionTier: 'BASIS' });
const pro = account({ id: 'pro', subscriptionTier: 'PRO' });
const noSubscription = account({ id: 'none', subscriptionTier: null });
const admin = account({
  id: 'admin',
  role: 'ADMIN',
  subscriptionTier: 'BASIS', // an admin's stored tier must not limit them
  modules: { ...noModuleAccess(), community: 'WRITE' },
});

type FeedPost = { id: string; locked: boolean } & Record<string, unknown>;

async function feed(user: AuthenticatedUser) {
  const { service } = harness();
  const data = await service.bootstrap(user);
  const posts = data.posts as unknown as FeedPost[];
  return { data, posts, byId: (id: string) => posts.find((post) => post.id === id)! };
}

describe('subscription tier rules', () => {
  it('gives administrators Pro, an account without subscription the fallback, others their own tier', () => {
    expect(effectiveTier({ role: 'ADMIN', subscriptionTier: 'BASIS' })).toBe('PRO');
    expect(effectiveTier({ role: 'ADMIN', subscriptionTier: null })).toBe('PRO');
    expect(effectiveTier({ role: 'USER', subscriptionTier: 'BASIS' })).toBe('BASIS');
    expect(effectiveTier({ role: 'USER', subscriptionTier: 'PRO' })).toBe('PRO');
    expect(effectiveTier({ role: 'USER', subscriptionTier: null })).toBe(NO_SUBSCRIPTION_FALLBACK_TIER);
    expect(NO_SUBSCRIPTION_FALLBACK_TIER).toBe('PRO');
  });

  it('lets Pro include Basis and treats no minimum as open', () => {
    expect(tierSatisfies('BASIS', null)).toBe(true);
    expect(tierSatisfies('BASIS', 'BASIS')).toBe(true);
    expect(tierSatisfies('BASIS', 'PRO')).toBe(false);
    expect(tierSatisfies('PRO', 'BASIS')).toBe(true);
    expect(tierSatisfies('PRO', 'PRO')).toBe(true);
  });
});

describe('what a Basis account receives', () => {
  it('sees CK Daily (Basis edition only) and CK Trading Info in full', async () => {
    const { byId } = await feed(basis);
    expect(byId('daily-basis').locked).toBe(false);
    expect(byId('info-1').locked).toBe(false);
  });

  it('gets no Pro cards in the Daily list, only one notice on the channel', async () => {
    const { data, posts } = await feed(basis);
    const daily = posts.filter((post) => post.channelId === 'ck-daily');
    expect(daily.map((post) => post.id)).toEqual(['daily-basis']);
    expect(posts.some((post) => post.id === 'daily-pro')).toBe(false);
    const withheld = Object.fromEntries(data.channels.map((channel) => [channel.id, channel.editionsWithheld]));
    expect(withheld).toEqual({
      'ck-daily': true,
      'ck-macro': false,
      'ck-charts': false,
      'ck-trading-info': false,
    });
  });

  it('sees CK Macro and CK Charts & Education only as locked channels with locked teasers', async () => {
    const { data, byId } = await feed(basis);
    const locked = Object.fromEntries(data.channels.map((channel) => [channel.id, channel.locked]));
    expect(locked).toEqual({
      'ck-daily': false,
      'ck-macro': true,
      'ck-charts': true,
      'ck-trading-info': false,
    });
    expect(byId('macro-1').locked).toBe(true);
    expect(byId('charts-1').locked).toBe(true);
  });

  it('never carries more than the teaser for a locked post', async () => {
    const { posts } = await feed(basis);
    const lockedPosts = posts.filter((post) => post.locked);
    expect(lockedPosts.map((post) => post.id).sort()).toEqual(['charts-1', 'macro-1']);
    for (const post of lockedPosts) {
      expect(Object.keys(post).sort()).toEqual(
        ['channelId', 'excerpt', 'id', 'legacyDate', 'locked', 'publishedAt', 'status', 'title', 'variant'].sort(),
      );
    }
    // And nothing of the Pro content anywhere in the serialized payload.
    const { data } = await feed(basis);
    expect(JSON.stringify(data)).not.toContain(SECRET);
    expect(JSON.stringify(lockedPosts)).not.toContain('data:image');
  });

  it('answers a direct open of a Pro edition or a Pro channel with the teaser, no body, no view', async () => {
    const { service, incrementViews, markPostRead } = harness();
    for (const id of ['daily-pro', 'macro-1', 'charts-1']) {
      const opened = (await service.post(basis, id)) as unknown as Record<string, unknown>;
      expect(opened.locked).toBe(true);
      expect(opened).not.toHaveProperty('body');
      expect(opened).not.toHaveProperty('comments');
      expect(JSON.stringify(opened)).not.toContain(SECRET);
    }
    expect(incrementViews).not.toHaveBeenCalled();
    expect(markPostRead).not.toHaveBeenCalled();
  });

  it('opens the Basis edition and reports its Pro counterpart only as a locked hint', async () => {
    const { service } = harness();
    const opened = (await service.post(basis, 'daily-basis')) as unknown as {
      locked: boolean;
      body: string;
      pairedPost: { id: string; locked: boolean };
    };
    expect(opened.locked).toBe(false);
    expect(opened.body).toContain('daily-basis');
    expect(opened.pairedPost).toMatchObject({ id: 'daily-pro', locked: true });
  });

  it('cannot like or comment on a locked post', async () => {
    const { service, setLike, createComment } = harness();
    await expect(service.setLike(basis, 'macro-1', true)).rejects.toBeInstanceOf(NotFoundException);
    await expect(service.addComment(basis, 'daily-pro', { body: 'x' })).rejects.toBeInstanceOf(NotFoundException);
    expect(setLike).not.toHaveBeenCalled();
    expect(createComment).not.toHaveBeenCalled();
  });

  it('does not count locked posts as unread', async () => {
    const { service, unreadCounts } = harness();
    await service.unreadCounts(basis);
    expect(unreadCounts).toHaveBeenCalledWith(
      expect.objectContaining({ channelIds: ['ck-daily', 'ck-trading-info'], variants: ['BASIS'] }),
    );
  });
});

describe('what a Pro account, an administrator and an account without subscription receive', () => {
  it('gives Pro both editions of the Daily, linked, plus every channel', async () => {
    const { data, posts } = await feed(pro);
    expect(posts.map((post) => post.id)).toEqual(POSTS.map((post) => post.id));
    expect(posts.every((post) => !post.locked)).toBe(true);
    expect(data.channels.every((channel) => !channel.locked && !channel.editionsWithheld)).toBe(true);

    const { service } = harness();
    const opened = (await service.post(pro, 'daily-pro')) as unknown as {
      body: string;
      pairedPost: { id: string; locked: boolean };
    };
    expect(opened.body).toContain(SECRET);
    expect(opened.pairedPost).toMatchObject({ id: 'daily-basis', locked: false });
    const other = (await service.post(pro, 'daily-basis')) as unknown as { body: string };
    expect(other.body).toContain('daily-basis');
  });

  it('gives an administrator everything, whatever tier is stored on the account', async () => {
    const { data, posts } = await feed(admin);
    expect(posts.map((post) => post.id)).toEqual(POSTS.map((post) => post.id));
    expect(posts.every((post) => !post.locked)).toBe(true);
    expect(data.channels.every((channel) => !channel.editionsWithheld)).toBe(true);
    expect(data.viewer.tier).toBe('PRO');
  });

  it('treats an account without subscription exactly like Pro (transition rule)', async () => {
    const without = await feed(noSubscription);
    const withPro = await feed(pro);
    expect(without.data.viewer.tier).toBe('PRO');
    expect(without.posts.map((post) => post.locked)).toEqual(withPro.posts.map((post) => post.locked));
    expect(without.data.channels.map((channel) => channel.locked)).toEqual(
      withPro.data.channels.map((channel) => channel.locked),
    );
  });

  it('marks the other edition read too when one is opened, and counts everything for Pro', async () => {
    const { service, markPostRead, unreadCounts } = harness();
    await service.post(pro, 'daily-pro');
    expect(markPostRead).toHaveBeenCalledWith('daily-pro', 'pro');
    expect(markPostRead).toHaveBeenCalledWith('daily-basis', 'pro');
    await service.unreadCounts(pro);
    expect(unreadCounts).toHaveBeenCalledWith(
      expect.objectContaining({
        channelIds: ['ck-daily', 'ck-macro', 'ck-charts', 'ck-trading-info'],
        variants: ['PRO', 'BASIS'],
      }),
    );
  });
});

describe('cover images', () => {
  it('are never inlined in the list, only a cache key for the image endpoint', async () => {
    const { data } = await feed(pro);
    expect(JSON.stringify(data)).not.toContain('data:image');
    const daily = (data.posts as unknown as Array<{ id: string; coverVersion?: string | null }>).find(
      (post) => post.id === 'daily-pro',
    );
    expect(daily?.coverVersion).toBe('1');
  });

  it('serves the cover bytes to a reader who may open the post', async () => {
    const { service } = harness();
    const cover = await service.postCover(pro, 'daily-pro');
    expect(cover.contentType).toBe('image/png');
    expect(cover.data.length).toBeGreaterThan(0);
  });

  it('answers not found for a locked post, a missing post and anything but a raster data URL', async () => {
    const { service } = harness();
    await expect(service.postCover(basis, 'daily-pro')).rejects.toBeInstanceOf(NotFoundException);
    await expect(service.postCover(basis, 'macro-1')).rejects.toBeInstanceOf(NotFoundException);
    await expect(service.postCover(pro, 'missing')).rejects.toBeInstanceOf(NotFoundException);
    POSTS.push(row('svg-cover', 'ck-daily', 'BASIS', { cover: 'data:image/svg+xml;base64,PHN2Zz4=' }));
    try {
      await expect(service.postCover(pro, 'svg-cover')).rejects.toBeInstanceOf(NotFoundException);
    } finally {
      POSTS.pop();
    }
    expect((await service.postCover(basis, 'daily-basis')).contentType).toBe('image/png');
  });
});

describe('push notification audience', () => {
  it('tells Pro readers about the Pro edition and Basis readers about the Basis edition, once each', () => {
    expect(notifiedTier({ variant: 'PRO', minTier: null })).toBe('PRO');
    expect(notifiedTier({ variant: 'BASIS', minTier: null })).toBe('BASIS');
    // A Basis edition inside a Pro channel can only be opened by Pro readers.
    expect(notifiedTier({ variant: 'BASIS', minTier: 'PRO' })).toBe('PRO');
  });

  it('never sends a Basis reader a notification for a locked channel', async () => {
    const recipient = (ownerId: string, subscriptionTier: 'BASIS' | 'PRO') => ({
      ownerId,
      subscription: { endpoint: `https://push.example.test/${ownerId}`, keys: { p256dh: 'p', auth: 'a' } },
      recipient: { role: 'USER' as const, subscriptionTier, editor: false },
    });
    const repository = {
      recipients: vi.fn().mockResolvedValue([recipient('basis', 'BASIS'), recipient('pro', 'PRO')]),
      remove: vi.fn(),
    } as unknown as CommunityPushRepository;
    const config = { get: (key: string) => `test-${key}` } as unknown as ConfigService;
    const push = new CommunityPushService(config, repository);
    const send = vi.mocked(webPush.sendNotification);
    const notifiedOwners = () => send.mock.calls.map(([subscription]) => subscription.endpoint.split('/').pop());

    for (const channel of ['CK Macro', 'CK Charts & Education', 'CK DeepDive']) {
      send.mockClear();
      await push.notifyPostPublished({ id: 'p', channelName: channel, visibility: 'MEMBERS', minTier: 'PRO', variant: 'PRO' });
      expect(notifiedOwners()).toEqual(['pro']);
    }
    send.mockClear();
    await push.notifyPostPublished({ id: 'b', channelName: 'CK Daily', visibility: 'MEMBERS', minTier: null, variant: 'BASIS' });
    expect(notifiedOwners()).toEqual(['basis']);
  });
});
