import {
  BadRequestException,
  ForbiddenException,
  Injectable,
  NotFoundException,
} from '@nestjs/common';
import type { AuthenticatedUser } from '../../common/auth/authenticated-user';
import { satisfies } from '../../common/access/modules';
import {
  type ChannelVisibility,
  CommunityRepository,
  type PostStatus,
} from './community.repository';
import type { CreatePostDto, UpdatePostDto } from './dto/post.dto';
import type { CreateChannelDto, UpdateChannelDto } from './dto/channel.dto';
import type { CreateChatDto, CreateMessageDto } from './dto/chat.dto';
import type { CreateCommentDto } from './dto/comment.dto';
import type { CreateAnnouncementDto, UpdateAnnouncementDto } from './dto/announcement.dto';
import type { SavePushSubscriptionDto } from './dto/push-subscription.dto';
import { CommunityPushService } from './community-push.service';

const MESSAGE_PAGE_SIZE = 200;

/**
 * The rules of Community & Research.
 *
 * There is exactly one rights model: the `community` entitlement from the admin
 * cockpit. READ is a reader — channels, published posts, comments, chat. WRITE
 * is an editor — publishing, editing, channels. The ADMIN *role* additionally
 * opens admin-only channels and the portal settings.
 *
 * The legacy portal carried a second, parallel model (its own member list, its
 * own Admin/Redakteur/Leser roles and its own password-less login). That one is
 * being retired: it could not see real accounts, and two sources of truth for
 * "who may publish" is one too many.
 */
@Injectable()
export class CommunityService {
  constructor(
    private readonly repository: CommunityRepository,
    private readonly push: CommunityPushService,
  ) {}

  private canWrite(user: AuthenticatedUser): boolean {
    return satisfies(user.modules.community, 'WRITE');
  }

  private requireWrite(user: AuthenticatedUser): void {
    if (!this.canWrite(user)) {
      throw new ForbiddenException('Writing to the community requires edit access.');
    }
  }

  private visibilitiesFor(user: AuthenticatedUser): ChannelVisibility[] {
    return user.role === 'ADMIN' ? ['MEMBERS', 'ADMINS'] : ['MEMBERS'];
  }

  /** Readers see published posts only; editors also see drafts. */
  private statusesFor(user: AuthenticatedUser): PostStatus[] {
    return this.canWrite(user) ? ['DRAFT', 'PUBLISHED'] : ['PUBLISHED'];
  }

  private displayName(user: AuthenticatedUser): string {
    return user.displayName?.trim() || user.email;
  }

  /** Everything the portal needs for its first paint, in one round trip. */
  async bootstrap(user: AuthenticatedUser) {
    const channels = await this.repository.listChannels(this.visibilitiesFor(user));
    const [posts, chats, settings, announcements, memberCount] = await Promise.all([
      this.repository.listPosts({
        channelIds: channels.map((channel) => channel.id),
        statuses: this.statusesFor(user),
        viewerId: user.id,
      }),
      this.repository.listChats(),
      this.repository.settings(),
      this.repository.listAnnouncements({
        statuses: this.statusesFor(user),
        viewerId: user.id,
      }),
      this.repository.activeMemberCount(),
    ]);

    return {
      viewer: {
        id: user.id,
        email: user.email,
        displayName: user.displayName,
        role: user.role,
        canWrite: this.canWrite(user),
        canManageSettings: user.role === 'ADMIN',
      },
      settings,
      channels,
      posts,
      chats,
      announcements,
      memberCount,
    };
  }

  /**
   * A post with its body and comments. Reading counts as a view, which is why
   * this is the only read that writes — and it is capped to what the reader may
   * actually open.
   */
  async post(user: AuthenticatedUser, id: string) {
    const post = await this.repository.postById(id);
    if (!post) throw new NotFoundException('Post not found.');

    const channel = await this.repository.channelById(post.channelId);
    if (!channel || !this.visibilitiesFor(user).includes(channel.visibility)) {
      // Same answer as a missing post: whether an admin-only channel exists is
      // not something a reader needs to learn.
      throw new NotFoundException('Post not found.');
    }
    if (post.status === 'DRAFT' && !this.canWrite(user)) {
      throw new NotFoundException('Post not found.');
    }

    await this.repository.incrementViews(id);

    const [comments, likes, likedByMe] = await Promise.all([
      this.repository.commentsForPost(id),
      this.repository.likeCount(id),
      this.repository.hasLiked(id, user.id),
    ]);

    return { ...post, views: post.views + 1, comments, likes, likedByMe };
  }

  async createPost(user: AuthenticatedUser, input: CreatePostDto) {
    this.requireWrite(user);
    const channel = await this.repository.channelById(input.channelId);
    if (!channel) throw new BadRequestException('Unknown channel.');

    const created = await this.repository.createPost({
      channelId: input.channelId,
      status: input.status ?? 'DRAFT',
      variant: input.variant ?? 'FULL',
      title: input.title.trim(),
      excerpt: input.excerpt?.trim() || null,
      body: input.body ?? '',
      cover: input.cover ?? null,
      authorId: user.id,
      authorName: this.displayName(user),
      publishedAt: input.status === 'PUBLISHED' ? new Date() : null,
    });
    if (created.status === 'PUBLISHED') {
      await this.push.notifyPostPublished({
        id: created.id,
        title: created.title,
        excerpt: created.excerpt,
        channelName: channel.name,
        visibility: channel.visibility,
      });
    }
    return created;
  }

  async updatePost(user: AuthenticatedUser, id: string, input: UpdatePostDto) {
    this.requireWrite(user);
    const post = await this.repository.postById(id);
    if (!post) throw new NotFoundException('Post not found.');

    if (input.channelId && !(await this.repository.channelById(input.channelId))) {
      throw new BadRequestException('Unknown channel.');
    }

    const patch: Parameters<CommunityRepository['updatePost']>[1] = {};
    if (input.channelId !== undefined) patch.channelId = input.channelId;
    if (input.title !== undefined) patch.title = input.title.trim();
    if (input.excerpt !== undefined) patch.excerpt = input.excerpt.trim() || null;
    if (input.body !== undefined) patch.body = input.body;
    if (input.cover !== undefined) patch.cover = input.cover || null;
    if (input.variant !== undefined) patch.variant = input.variant;
    if (input.status !== undefined) {
      patch.status = input.status;
      // First publication stamps the date; re-publishing an already published
      // post must not move it back to the top of the feed.
      if (input.status === 'PUBLISHED' && !post.publishedAt) patch.publishedAt = new Date();
    }

    if (!Object.keys(patch).length) throw new BadRequestException('No changes supplied.');
    const updated = await this.repository.updatePost(id, patch);
    if (input.status === 'PUBLISHED' && !post.publishedAt) {
      const channel = await this.repository.channelById(updated.channelId);
      if (channel) {
        await this.push.notifyPostPublished({
          id: updated.id,
          title: updated.title,
          excerpt: updated.excerpt,
          channelName: channel.name,
          visibility: channel.visibility,
        });
      }
    }
    return updated;
  }

  pushConfiguration() {
    return this.push.configuration();
  }

  async savePushSubscription(user: AuthenticatedUser, input: SavePushSubscriptionDto) {
    if (!this.push.configuration().enabled) {
      throw new BadRequestException('Push notifications are not configured.');
    }
    await this.push.save(user.id, {
      endpoint: input.endpoint,
      expirationTime: input.expirationTime ?? null,
      keys: input.keys,
    });
  }

  removePushSubscription(user: AuthenticatedUser, endpoint: string) {
    return this.push.remove(user.id, endpoint);
  }

  async deletePost(user: AuthenticatedUser, id: string) {
    this.requireWrite(user);
    if (!(await this.repository.postById(id))) throw new NotFoundException('Post not found.');
    await this.repository.deletePost(id);
  }

  async setLike(user: AuthenticatedUser, postId: string, liked: boolean) {
    const post = await this.repository.postById(postId);
    if (!post) throw new NotFoundException('Post not found.');
    await this.repository.setLike(postId, user.id, liked);
    return { likes: await this.repository.likeCount(postId), likedByMe: liked };
  }

  async addComment(user: AuthenticatedUser, postId: string, input: CreateCommentDto) {
    const post = await this.repository.postById(postId);
    if (!post) throw new NotFoundException('Post not found.');

    const body = input.body.trim();
    if (!body) throw new BadRequestException('The comment is empty.');

    return this.repository.createComment({
      postId,
      authorId: user.id,
      authorName: this.displayName(user),
      body,
    });
  }

  /** Anyone may remove their own comment; editors may remove any. */
  async deleteComment(user: AuthenticatedUser, id: string) {
    const comment = await this.repository.commentById(id);
    if (!comment) throw new NotFoundException('Comment not found.');
    if (comment.authorId !== user.id && !this.canWrite(user)) {
      throw new ForbiddenException('You can only delete your own comments.');
    }
    await this.repository.deleteComment(id);
  }

  async messages(user: AuthenticatedUser, chatId: string) {
    if (!(await this.repository.chatById(chatId))) throw new NotFoundException('Chat not found.');
    const rows = await this.repository.messagesForChat(chatId, MESSAGE_PAGE_SIZE);
    // Newest first from the database (so the limit keeps the *latest*), oldest
    // first for the reader.
    return rows.reverse();
  }

  async addMessage(user: AuthenticatedUser, chatId: string, input: CreateMessageDto) {
    if (!(await this.repository.chatById(chatId))) throw new NotFoundException('Chat not found.');
    const body = input.body.trim();
    if (!body) throw new BadRequestException('The message is empty.');

    return this.repository.createMessage({
      chatId,
      authorId: user.id,
      authorName: this.displayName(user),
      body,
    });
  }

  /**
   * Any member may open a topic chat — that is what makes it a community rather
   * than a newsletter. Channels stay editorial: a channel is where CK publishes,
   * a chat is where members talk.
   */
  async createChat(user: AuthenticatedUser, input: CreateChatDto) {
    const id = slug(input.name);
    if (!id) throw new BadRequestException('The name does not produce a usable id.');
    if (await this.repository.chatById(id)) throw new BadRequestException('That chat exists.');

    return this.repository.createChat({
      id,
      name: input.name.trim(),
      description: input.description?.trim() || null,
      createdById: user.id,
    });
  }

  async createChannel(user: AuthenticatedUser, input: CreateChannelDto) {
    this.requireWrite(user);
    const id = slug(input.name);
    if (!id) throw new BadRequestException('The name does not produce a usable id.');
    if (await this.repository.channelById(id)) throw new BadRequestException('That channel exists.');

    return this.repository.createChannel({
      id,
      name: input.name.trim(),
      icon: input.icon ?? null,
      description: input.description?.trim() || null,
      visibility: input.visibility ?? 'MEMBERS',
      position: input.position ?? 0,
    });
  }

  async updateChannel(user: AuthenticatedUser, id: string, input: UpdateChannelDto) {
    this.requireWrite(user);
    if (!(await this.repository.channelById(id))) throw new NotFoundException('Channel not found.');
    return this.repository.updateChannel(id, {
      ...(input.name !== undefined ? { name: input.name.trim() } : {}),
      ...(input.icon !== undefined ? { icon: input.icon || null } : {}),
      ...(input.description !== undefined
        ? { description: input.description.trim() || null }
        : {}),
      ...(input.visibility !== undefined ? { visibility: input.visibility } : {}),
      ...(input.position !== undefined ? { position: input.position } : {}),
    });
  }

  /**
   * Deleting a channel that still holds posts is refused rather than cascaded:
   * the posts are the product, and a channel is only a shelf.
   */
  async deleteChannel(user: AuthenticatedUser, id: string) {
    this.requireWrite(user);
    if (!(await this.repository.channelById(id))) throw new NotFoundException('Channel not found.');
    const posts = await this.repository.countPostsInChannel(id);
    if (posts > 0) {
      throw new BadRequestException(
        `This channel still holds ${posts} post(s). Move or delete them first.`,
      );
    }
    await this.repository.deleteChannel(id);
  }

  /**
   * Announcements reach every member, whatever channels they hold — so unlike a
   * post there is no channel visibility to check, only draft versus published.
   */
  async createAnnouncement(user: AuthenticatedUser, input: CreateAnnouncementDto) {
    this.requireWrite(user);
    const title = input.title.trim();
    if (!title) throw new BadRequestException('The announcement needs a title.');

    return this.repository.createAnnouncement({
      title,
      summary: input.summary?.trim() || null,
      body: input.body ?? '',
      cover: input.cover ?? null,
      pinned: input.pinned ?? false,
      status: input.status ?? 'DRAFT',
      authorId: user.id,
      authorName: this.displayName(user),
      publishedAt: input.status === 'PUBLISHED' ? new Date() : null,
    });
  }

  async updateAnnouncement(user: AuthenticatedUser, id: string, input: UpdateAnnouncementDto) {
    this.requireWrite(user);
    const existing = await this.repository.announcementById(id);
    if (!existing) throw new NotFoundException('Announcement not found.');

    const patch: Parameters<CommunityRepository['updateAnnouncement']>[1] = {};
    if (input.title !== undefined) patch.title = input.title.trim();
    if (input.summary !== undefined) patch.summary = input.summary.trim() || null;
    if (input.body !== undefined) patch.body = input.body;
    if (input.cover !== undefined) patch.cover = input.cover || null;
    if (input.pinned !== undefined) patch.pinned = input.pinned;
    if (input.status !== undefined) {
      patch.status = input.status;
      // First publication stamps the date; re-publishing must not reorder the
      // list under readers who have already seen it.
      if (input.status === 'PUBLISHED' && !existing.publishedAt) patch.publishedAt = new Date();
    }

    if (!Object.keys(patch).length) throw new BadRequestException('No changes supplied.');
    return this.repository.updateAnnouncement(id, patch);
  }

  async deleteAnnouncement(user: AuthenticatedUser, id: string) {
    this.requireWrite(user);
    if (!(await this.repository.announcementById(id))) {
      throw new NotFoundException('Announcement not found.');
    }
    await this.repository.deleteAnnouncement(id);
  }

  /** Marks it read for this account; repeated calls change nothing. */
  async markAnnouncementRead(user: AuthenticatedUser, id: string) {
    const announcement = await this.repository.announcementById(id);
    if (!announcement || announcement.status !== 'PUBLISHED') {
      throw new NotFoundException('Announcement not found.');
    }
    await this.repository.markAnnouncementRead(id, user.id);
    return { readByMe: true };
  }

  async updateSettings(user: AuthenticatedUser, values: Record<string, unknown>) {
    if (user.role !== 'ADMIN') {
      throw new ForbiddenException('Portal settings are restricted to administrators.');
    }
    const entries = Object.entries(values);
    if (!entries.length) throw new BadRequestException('No changes supplied.');
    for (const [key, value] of entries) await this.repository.setSetting(key, value);
    return this.repository.settings();
  }
}

/** Readable, stable channel and chat ids in the shape the legacy data uses. */
function slug(value: string): string {
  return value
    .toLowerCase()
    .normalize('NFD')
    .replace(/[\u0300-\u036f]/g, '')
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '')
    .slice(0, 64);
}
