import {
  BadRequestException,
  ForbiddenException,
  Injectable,
  NotFoundException,
} from '@nestjs/common';
import {
  isSupportAttachmentAllowed,
  Passenger as T,
  SUPPORT_ATTACHMENT_MAX_BYTES,
  SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
} from '@edr/types';
import { randomUUID } from 'crypto';
import { Readable } from 'stream';
import { PrismaService } from '../../common/prisma.service';
import { MinioService } from '../storage/minio.service';
import { decodeMessageCursor, encodeMessageCursor } from './message-cursor';
import { SUPPORT_MESSAGES_DEFAULT_LIMIT } from './support.dto';
import { SupportGateway } from './support.gateway';

type Side = 'USER' | 'AGENT';
type PrismaSender = 'USER' | 'BOT' | 'AGENT';
type PrismaStatus = 'OPEN' | 'RESOLVED' | 'CLOSED';

/** Stand-in preview for a message that is nothing but files. */
const ATTACHMENT_ONLY_PREVIEW = '📎';

interface MessagesQuery {
  before?: string;
  limit?: number;
}

type AttachmentRow = {
  id: string;
  name: string;
  mimeType: string;
  size: number;
  url: string;
};

type MessageRow = {
  id: string;
  conversationId: string;
  sender: PrismaSender;
  text: string | null;
  createdAt: Date;
  attachments?: AttachmentRow[];
};

/** Who the caller is on the customer side: an authed passenger or a guest. */
export interface CustomerOwner {
  iamUserId?: string | null;
  guestId?: string | null;
}

interface ListQuery {
  status?: PrismaStatus;
  search?: string;
  page?: number;
  limit?: number;
}

type ConversationRow = {
  id: string;
  userId: string | null;
  guestId: string | null;
  guestName: string | null;
  guestEmail: string | null;
  guestPhone: string | null;
  passengerId: string | null;
  passengerName: string | null;
  subject: string | null;
  status: PrismaStatus;
  assignedAgentId: string | null;
  lastMessageAt: Date | null;
  lastMessagePreview: string | null;
  lastMessageSender: PrismaSender | null;
  userLastReadAt: Date | null;
  agentLastReadAt: Date | null;
  createdAt: Date;
  updatedAt: Date;
};

@Injectable()
export class SupportService {
  constructor(
    private prisma: PrismaService,
    private gateway: SupportGateway,
    private minio: MinioService,
  ) {}

  // ---- FAQ (unchanged) ---------------------------------------------------

  getFaqCategories() {
    return this.prisma.faqCategory.findMany({
      include: { _count: { select: { articles: true } } },
    });
  }

  getFaqArticles(categoryId: string) {
    return this.prisma.faqArticle.findMany({
      where: { categoryId },
      orderBy: { rank: 'asc' },
    });
  }

  // ---- customer: authed passenger ---------------------------------------

  async createConversation(
    iamUserId: string,
    input: { subject: string; initialMessage: string },
  ): Promise<T.PassengerSupportConversationDto> {
    const passenger = await this.prisma.passenger.findUnique({
      where: { iamUserId },
      include: { user: { select: { fullName: true } } },
    });
    const conversation = (await this.prisma.supportConversation.create({
      data: {
        userId: iamUserId,
        passengerId: passenger?.id ?? null,
        passengerName: passenger?.user?.fullName ?? null,
        subject: input.subject,
        status: 'OPEN',
      },
    })) as ConversationRow;
    return this.firstMessage(conversation, input.initialMessage);
  }

  // ---- customer: guest (unauthenticated) --------------------------------

  async createGuestConversation(input: {
    guestId: string;
    name: string;
    email: string;
    phone?: string;
    subject: string;
    initialMessage: string;
  }): Promise<T.PassengerSupportConversationDto> {
    const conversation = (await this.prisma.supportConversation.create({
      data: {
        guestId: input.guestId,
        guestName: input.name,
        guestEmail: input.email,
        guestPhone: input.phone ?? null,
        passengerName: input.name, // uniform display name for the agent inbox
        subject: input.subject,
        status: 'OPEN',
      },
    })) as ConversationRow;
    return this.firstMessage(conversation, input.initialMessage);
  }

  // ---- customer: device-scoped single thread (portal) -------------------

  /**
   * The device's single conversation + its NEWEST page of messages
   * ({conversation:null} if none). Not the whole thread — the client pages back
   * with `nextCursor` exactly as the backoffice does.
   */
  async getDeviceThread(
    deviceId: string,
    query: MessagesQuery = {},
  ): Promise<T.PassengerSupportThreadDto> {
    const empty = { conversation: null, messages: [], nextCursor: null };
    if (!deviceId) return empty;
    const c = (await this.prisma.supportConversation.findFirst({
      where: { guestId: deviceId },
      orderBy: { createdAt: 'asc' },
    })) as ConversationRow | null;
    if (!c) return empty;

    const page = await this.listMessages(c.id, query);
    const unread = await this.computeUnread([c], 'USER');
    return {
      conversation: this.toConversationDto(c, unread.get(c.id) ?? 0),
      messages: page.items,
      nextCursor: page.nextCursor,
    };
  }

  /** Append a message to the device's thread, creating it on first message. */
  async sendDeviceMessage(
    deviceId: string,
    text: string | undefined,
    attachments: Express.Multer.File[] = [],
  ): Promise<T.PassengerSupportMessageDto> {
    let c = (await this.prisma.supportConversation.findFirst({
      where: { guestId: deviceId },
      orderBy: { createdAt: 'asc' },
    })) as ConversationRow | null;
    if (!c) {
      c = (await this.prisma.supportConversation.create({
        data: { guestId: deviceId, subject: 'Support chat', status: 'OPEN' },
      })) as ConversationRow;
    }
    return this.appendMessage(c, 'USER', text, attachments);
  }

  /** Mark the device's thread read (customer side). */
  async markDeviceRead(deviceId: string): Promise<{ unreadCount: number }> {
    const c = await this.prisma.supportConversation.findFirst({
      where: { guestId: deviceId },
      orderBy: { createdAt: 'asc' },
    });
    if (c) {
      await this.prisma.supportConversation.update({
        where: { id: c.id },
        data: { userLastReadAt: new Date() },
      });
    }
    return this.unreadCount('USER', { guestId: deviceId });
  }

  async listForCustomer(
    owner: CustomerOwner,
    query: ListQuery,
  ): Promise<T.PassengerSupportConversationListResult> {
    const scope = this.ownerScope(owner);
    const where = { ...this.listWhere(query), ...scope };
    const rows = (await this.prisma.supportConversation.findMany({
      where,
      orderBy: [{ lastMessageAt: 'desc' }, { createdAt: 'desc' }],
      take: query.limit ?? 100,
      skip: ((query.page ?? 1) - 1) * (query.limit ?? 100),
    })) as ConversationRow[];
    const count = await this.prisma.supportConversation.count({ where });
    return this.buildListResult(rows, count, 'USER');
  }

  // ---- agent (backoffice) ------------------------------------------------

  async listForAgents(query: ListQuery): Promise<T.PassengerSupportConversationListResult> {
    const where = this.listWhere(query);
    const rows = (await this.prisma.supportConversation.findMany({
      where,
      orderBy: [{ lastMessageAt: 'desc' }, { createdAt: 'desc' }],
      take: query.limit ?? 100,
      skip: ((query.page ?? 1) - 1) * (query.limit ?? 100),
    })) as ConversationRow[];
    const count = await this.prisma.supportConversation.count({ where });
    return this.buildListResult(rows, count, 'AGENT');
  }

  async setStatus(
    conversationId: string,
    status: PrismaStatus,
  ): Promise<T.PassengerSupportConversationDto> {
    await this.requireConversation(conversationId);
    const updated = (await this.prisma.supportConversation.update({
      where: { id: conversationId },
      data: { status },
    })) as ConversationRow;
    const dto = this.toConversationDto(updated, 0);
    this.gateway.emitConversationUpdated(this.ownerRoom(updated), dto);
    return dto;
  }

  // ---- shared ------------------------------------------------------------

  /** One page of a thread, newest first. See {@link listMessages}. */
  async getMessages(
    conversationId: string,
    query: MessagesQuery = {},
    asCustomer?: CustomerOwner,
  ): Promise<T.PassengerSupportMessageListResult> {
    const conversation = await this.requireConversation(conversationId);
    if (asCustomer) this.assertOwns(conversation, asCustomer);
    return this.listMessages(conversationId, query);
  }

  async sendMessage(
    conversationId: string,
    sender: Side,
    text: string | undefined,
    asCustomer?: CustomerOwner,
    attachments: Express.Multer.File[] = [],
  ): Promise<T.PassengerSupportMessageDto> {
    const conversation = await this.requireConversation(conversationId);
    if (sender === 'USER') {
      this.assertOwns(conversation, asCustomer ?? {});
    }
    return this.appendMessage(conversation, sender, text, attachments);
  }

  async markRead(
    conversationId: string,
    side: Side,
    asCustomer?: CustomerOwner,
  ): Promise<{ unreadCount: number }> {
    const conversation = await this.requireConversation(conversationId);
    if (side === 'USER') {
      this.assertOwns(conversation, asCustomer ?? {});
      await this.prisma.supportConversation.update({
        where: { id: conversationId },
        data: { userLastReadAt: new Date() },
      });
      return this.unreadCount('USER', asCustomer);
    }
    await this.prisma.supportConversation.update({
      where: { id: conversationId },
      data: { agentLastReadAt: new Date() },
    });
    return this.unreadCount('AGENT');
  }

  async unreadCount(side: Side, owner?: CustomerOwner): Promise<{ unreadCount: number }> {
    const rows = (await this.prisma.supportConversation.findMany({
      where: side === 'USER' ? this.ownerScope(owner ?? {}) : {},
      select: { id: true, userLastReadAt: true, agentLastReadAt: true },
    })) as Array<{
      id: string;
      userLastReadAt: Date | null;
      agentLastReadAt: Date | null;
    }>;
    const map = await this.computeUnread(rows, side);
    let unreadCount = 0;
    for (const n of map.values()) if (n > 0) unreadCount++;
    return { unreadCount };
  }

  // ---- internals ---------------------------------------------------------

  private async firstMessage(
    conversation: ConversationRow,
    text: string,
  ): Promise<T.PassengerSupportConversationDto> {
    const { conversation: updated } = await this.appendMessageRaw(conversation, 'USER', text);
    return this.toConversationDto(updated, 0);
  }

  private async appendMessage(
    conversation: ConversationRow,
    sender: PrismaSender,
    text: string | undefined,
    attachments: Express.Multer.File[] = [],
  ): Promise<T.PassengerSupportMessageDto> {
    const { message } = await this.appendMessageRaw(conversation, sender, text, attachments);
    return message;
  }

  /**
   * One page of a thread, walking backwards from newest.
   *
   * Keyset, not offset: a message arriving while the reader is scrolled back
   * would shift every offset by one and duplicate/skip rows across pages. Rides
   * the (conversationId, createdAt) index; the id is a tiebreak for messages
   * sharing a millisecond.
   */
  private async listMessages(
    conversationId: string,
    query: MessagesQuery,
  ): Promise<T.PassengerSupportMessageListResult> {
    const limit = query.limit ?? SUPPORT_MESSAGES_DEFAULT_LIMIT;
    const before = query.before ? decodeMessageCursor(query.before) : undefined;

    const rows = (await this.prisma.supportMessage.findMany({
      where: {
        conversationId,
        ...(before
          ? {
              // Strictly older than the cursor in (createdAt, id) order.
              OR: [
                { createdAt: { lt: before.createdAt } },
                {
                  createdAt: before.createdAt,
                  id: { lt: before.id },
                },
              ],
            }
          : {}),
      },
      orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
      // One more than asked, to tell "there's another page" from "this page was
      // simply full" without a second COUNT.
      take: limit + 1,
      include: { attachments: { orderBy: { createdAt: 'asc' } } },
    })) as MessageRow[];

    const hasMore = rows.length > limit;
    const page = hasMore ? rows.slice(0, limit) : rows;
    const oldest = page[page.length - 1];
    const nextCursor =
      hasMore && oldest
        ? encodeMessageCursor({ createdAt: oldest.createdAt, id: oldest.id })
        : null;

    // Flip to oldest-first so a page prepends as one block.
    const items = await Promise.all([...page].reverse().map((m) => this.toMessageDto(m)));
    return { items, nextCursor };
  }

  /** Persist a message (+ attachments), bump denormalized fields, emit live. */
  private async appendMessageRaw(
    conversation: ConversationRow,
    sender: PrismaSender,
    text: string | undefined,
    attachments: Express.Multer.File[] = [],
  ): Promise<{
    conversation: ConversationRow;
    message: T.PassengerSupportMessageDto;
  }> {
    const trimmed = (text ?? '').trim();
    this.assertSendable(trimmed, attachments);

    const message = await this.prisma.supportMessage.create({
      // NULL, not "", so "no text" is representable rather than inferred. The
      // DTO flattens it back to "" for rendering.
      data: { conversationId: conversation.id, sender, text: trimmed || null },
    });

    // The row has to exist before the files, since each is stored against
    // `messageId`. That leaves a window: if an upload fails here, the message is
    // already committed. Undo it rather than leave the thread with a
    // permanently blank bubble — there is no delete flow, so an orphan would be
    // unremovable, and an attachment-only message that lost its files has no
    // content at all.
    let stored: AttachmentRow[];
    try {
      stored = await this.storeAttachments(message.id, attachments);
    } catch (error) {
      await this.prisma.supportMessage.delete({ where: { id: message.id } });
      throw error;
    }

    const updated = (await this.prisma.supportConversation.update({
      where: { id: conversation.id },
      data: {
        lastMessageAt: message.createdAt,
        lastMessagePreview: this.buildPreview(trimmed, stored),
        lastMessageSender: sender,
      },
      // Deliberately NOT `include: { messages: ... }` — that loaded every
      // message in the thread on every send just to read back the one we had in
      // hand.
    })) as ConversationRow;

    const messageDto = await this.toMessageDto({
      ...(message as MessageRow),
      attachments: stored,
    });
    const dto = this.toConversationDto(updated, 0);
    this.gateway.emitMessage(this.ownerRoom(updated), dto, messageDto);
    return { conversation: updated, message: messageDto };
  }

  /**
   * Push bytes to MinIO, then record them. Object keys are namespaced by message
   * id and the stored name is sanitized so the key survives the round-trip
   * through its own URL (spaces/unicode would otherwise percent-encode and no
   * longer match the key).
   */
  private async storeAttachments(
    messageId: string,
    files: Express.Multer.File[],
  ): Promise<AttachmentRow[]> {
    return Promise.all(
      files.map(async (file) => {
        const safeName = file.originalname
          .normalize('NFKD')
          .replace(/[^\w.\-]+/g, '_')
          .replace(/_{2,}/g, '_')
          .replace(/^_+|_+$/g, '');
        // The random segment is load-bearing: `Date.now()` is NOT unique across
        // this batch, since every callback runs to its first await in the same
        // tick and reads the same millisecond. Two files sharing a name — e.g.
        // two pasted screenshots, which browsers both call "image.png" — would
        // otherwise build the same key and silently overwrite each other.
        const objectName = `support_message/${messageId}/${Date.now()}_${randomUUID().slice(0, 8)}_${safeName}`;
        const url = await this.minio.uploadFile(objectName, file.buffer, file.mimetype);
        return this.prisma.supportAttachment.create({
          data: {
            messageId,
            name: file.originalname,
            mimeType: file.mimetype,
            size: file.size,
            url,
          },
        });
      }),
    );
  }

  /**
   * Chat upload rules — kept in step with the freight side via the shared
   * SUPPORT_ATTACHMENT_* constants. Notably excludes SVG: it's executable markup
   * and this is a file one user pushes at another.
   */
  private assertSendable(text: string, attachments: Express.Multer.File[]): void {
    if (!text && attachments.length === 0) {
      throw new BadRequestException('A message needs text or at least one attachment.');
    }
    if (attachments.length > SUPPORT_ATTACHMENT_MAX_PER_MESSAGE) {
      throw new BadRequestException(
        `At most ${SUPPORT_ATTACHMENT_MAX_PER_MESSAGE} files per message.`,
      );
    }
    for (const file of attachments) {
      if (!isSupportAttachmentAllowed(file.mimetype)) {
        throw new BadRequestException(`Unsupported attachment type: ${file.mimetype}`);
      }
      if (file.size > SUPPORT_ATTACHMENT_MAX_BYTES) {
        throw new BadRequestException(
          `"${file.originalname}" exceeds the ${
            SUPPORT_ATTACHMENT_MAX_BYTES / (1024 * 1024)
          }MB attachment limit.`,
        );
      }
    }
  }

  /** Inbox preview line — falls back to filenames when there's no text. */
  private buildPreview(text: string, attachments: AttachmentRow[]): string {
    if (text) return text.slice(0, 280);
    if (attachments.length === 1) {
      return `${ATTACHMENT_ONLY_PREVIEW} ${attachments[0].name}`.slice(0, 280);
    }
    return `${ATTACHMENT_ONLY_PREVIEW} ${attachments.length} files`;
  }

  private async buildListResult(
    rows: ConversationRow[],
    count: number,
    side: Side,
  ): Promise<T.PassengerSupportConversationListResult> {
    const unreadMap = await this.computeUnread(rows, side);
    const items = rows.map((r) => this.toConversationDto(r, unreadMap.get(r.id) ?? 0));
    let unreadCount = 0;
    for (const n of unreadMap.values()) if (n > 0) unreadCount++;
    return { items, count, unreadCount };
  }

  private async computeUnread(
    rows: Array<{
      id: string;
      userLastReadAt: Date | null;
      agentLastReadAt: Date | null;
    }>,
    side: Side,
  ): Promise<Map<string, number>> {
    const map = new Map<string, number>();
    if (rows.length === 0) return map;
    const otherSender: PrismaSender = side === 'USER' ? 'AGENT' : 'USER';
    const ids = rows.map((r) => r.id);
    const msgs = await this.prisma.supportMessage.findMany({
      where: { conversationId: { in: ids }, sender: otherSender },
      select: { conversationId: true, createdAt: true },
    });
    const cursorById = new Map(
      rows.map((r) => [r.id, side === 'USER' ? r.userLastReadAt : r.agentLastReadAt]),
    );
    for (const m of msgs) {
      const cursor = cursorById.get(m.conversationId) ?? null;
      if (!cursor || m.createdAt > cursor) {
        map.set(m.conversationId, (map.get(m.conversationId) ?? 0) + 1);
      }
    }
    return map;
  }

  private listWhere(query: ListQuery) {
    const where: Record<string, unknown> = {};
    if (query.status) where.status = query.status;
    if (query.search?.trim()) {
      const contains = query.search.trim();
      where.OR = [
        { subject: { contains, mode: 'insensitive' } },
        { passengerName: { contains, mode: 'insensitive' } },
        { guestEmail: { contains, mode: 'insensitive' } },
      ];
    }
    return where;
  }

  /** Prisma where-fragment scoping to the calling customer (authed or guest). */
  private ownerScope(owner: CustomerOwner): Record<string, unknown> {
    if (owner.iamUserId) return { userId: owner.iamUserId };
    if (owner.guestId) return { guestId: owner.guestId };
    // No identity ⇒ match nothing.
    return { id: '__none__' };
  }

  private assertOwns(conversation: ConversationRow, owner: CustomerOwner): void {
    const ok =
      (owner.iamUserId && conversation.userId === owner.iamUserId) ||
      (owner.guestId && conversation.guestId === owner.guestId);
    if (!ok) {
      throw new ForbiddenException('This conversation belongs to someone else.');
    }
  }

  private ownerRoom(c: ConversationRow): string | null {
    if (c.guestId) return `guest:${c.guestId}`;
    if (c.userId) return `user:${c.userId}`;
    return null;
  }

  private async requireConversation(id: string): Promise<ConversationRow> {
    const conversation = (await this.prisma.supportConversation.findUnique({
      where: { id },
    })) as ConversationRow | null;
    if (!conversation) throw new NotFoundException('Conversation not found');
    return conversation;
  }

  private toConversationDto(
    c: ConversationRow,
    unreadCount: number,
  ): T.PassengerSupportConversationDto {
    return {
      id: c.id,
      userId: c.userId,
      guestId: c.guestId,
      guestEmail: c.guestEmail,
      guestPhone: c.guestPhone,
      passengerId: c.passengerId,
      passengerName: c.passengerName ?? c.guestName ?? null,
      subject: c.subject,
      status: c.status as T.PassengerSupportStatus,
      assignedAgentId: c.assignedAgentId,
      lastMessageAt: c.lastMessageAt ? c.lastMessageAt.toISOString() : null,
      lastMessagePreview: c.lastMessagePreview,
      lastMessageSender: this.toDtoSender(c.lastMessageSender),
      unreadCount,
      createdAt: c.createdAt.toISOString(),
      updatedAt: c.updatedAt.toISOString(),
    };
  }

  private async toMessageDto(m: MessageRow): Promise<T.PassengerSupportMessageDto> {
    return {
      id: m.id,
      conversationId: m.conversationId,
      sender: this.toDtoSender(m.sender) ?? T.PassengerSupportSender.AGENT,
      text: m.text ?? '',
      attachments: (m.attachments ?? []).map((a) => this.toAttachmentDto(a)),
      createdAt: m.createdAt.toISOString(),
    };
  }

  /**
   * Where the client fetches the bytes: this API's own stream route, NOT a
   * presigned MinIO URL. Presigned object URLs are not reachable from the
   * browser in this deployment, which is why every working file in the platform
   * streams through the API instead.
   *
   * The path is audience-independent on purpose. A new message is pushed over
   * the socket to the device room *and* the backoffice room in one payload, so a
   * URL that embedded the caller's identity (a `?deviceId=`, say) would be wrong
   * for one of the two recipients.
   *
   * The client can't use this path as an `<img src>` either — the agent side's
   * guard only reads a bearer header, which an image request can't send — so the
   * web apps fetch it through their authenticated client and render a blob.
   */
  private toAttachmentDto(a: AttachmentRow): T.PassengerSupportAttachmentDto {
    return {
      id: a.id,
      name: a.name,
      mimeType: a.mimeType,
      size: a.size,
      url: `/support/attachments/${a.id}`,
    };
  }

  /**
   * Bytes for a chat attachment.
   *
   * Deliberately unscoped, and this is a trade-off worth naming: passenger
   * support threads are already reachable by whoever holds the device/guest id
   * (see the device routes — "anyone with the device id can see that thread",
   * the accepted MVP posture), and the agent routes admit any authenticated
   * caller pending real staff gating. Scoping this endpoint tighter than the
   * thread it belongs to would buy nothing, so it matches that posture: the
   * attachment UUID is the capability.
   *
   * TODO: tighten alongside the agent-route staff permission — at that point
   * both the thread and its attachments should be gated the same way.
   */
  async streamAttachment(
    fileId: string,
  ): Promise<{ stream: Readable; mimeType: string; name: string }> {
    const attachment = await this.prisma.supportAttachment.findUnique({
      where: { id: fileId },
    });
    if (!attachment) throw new NotFoundException('Attachment not found');

    const objectName = this.minio.getObjectNameFromUrl(attachment.url);
    return {
      stream: await this.minio.getFileStream(objectName),
      mimeType: attachment.mimeType,
      name: attachment.name,
    };
  }

  /** Legacy BOT messages are surfaced as AGENT to the UI. */
  private toDtoSender(s: PrismaSender | null): T.PassengerSupportSender | null {
    if (!s) return null;
    return s === 'USER' ? T.PassengerSupportSender.USER : T.PassengerSupportSender.AGENT;
  }
}
