import {
  isSupportAttachmentAllowed,
  SendSupportMessageResult,
  SUPPORT_ATTACHMENT_MAX_BYTES,
  SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
  SUPPORT_ATTACHMENT_RESOURCE,
  SupportAttachmentDto,
  SupportAuthorRole,
  SupportConversationDto,
  SupportConversationListResult,
  SupportMessageDto,
  SupportMessageListResult,
} from "@edr/types";
import {
  BadRequestException,
  ForbiddenException,
  Injectable,
  NotFoundException,
} from "@nestjs/common";
import { QueryFailedError } from "typeorm";

import { BackofficeService } from "../backoffice/backoffice.service";
import { CompaniesService } from "../companies/companies.service";
import { ExternalProfileRepository } from "../companies/external-profile.repository";
import { FileRecord } from "../files/entities/file.entity";
import { FilesService } from "../files/files.service";
import { ListConversationsQueryDto } from "./dto/list-conversations-query.dto";
import {
  ListMessagesQueryDto,
  SUPPORT_MESSAGES_DEFAULT_LIMIT,
} from "./dto/list-messages-query.dto";
import { SupportConversation } from "./entities/support-conversation.entity";
import { SupportMessage } from "./entities/support-message.entity";
import { decodeMessageCursor, encodeMessageCursor } from "./message-cursor";
import { SupportChatGateway } from "./support-chat.gateway";
import { SupportConversationRepository } from "./support-conversation.repository";
import { SupportMessageRepository } from "./support-message.repository";

interface CustomerContext {
  companyId: string;
  companyName?: string | null;
  authorName?: string | null;
}

/** Postgres unique_violation — the one-thread-per-company index fired. */
const PG_UNIQUE_VIOLATION = "23505";

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

@Injectable()
export class SupportChatService {
  constructor(
    private readonly conversations: SupportConversationRepository,
    private readonly messages: SupportMessageRepository,
    private readonly gateway: SupportChatGateway,
    private readonly externalProfiles: ExternalProfileRepository,
    private readonly companies: CompaniesService,
    private readonly files: FilesService,
    private readonly backoffice: BackofficeService,
  ) {}

  // ---- customer (portal) -------------------------------------------------

  /**
   * The caller's company thread, or null if nobody has spoken yet. Deliberately
   * does *not* create: opening the widget shouldn't push an empty thread into
   * the agent inbox. Creation happens on the first message.
   */
  async getCustomerConversation(
    userId: string,
  ): Promise<SupportConversationDto | null> {
    const ctx = await this.resolveCustomer(userId);
    const conversation = await this.conversations.findByCompanyId(
      ctx.companyId,
    );
    if (!conversation) return null;
    const unread = await this.messages.unreadCountsByConversation(
      [conversation.id],
      SupportAuthorRole.CUSTOMER,
    );
    return this.toConversationDto(
      conversation,
      unread.get(conversation.id) ?? 0,
    );
  }

  async getCustomerMessages(
    userId: string,
    query: ListMessagesQueryDto = {},
  ): Promise<SupportMessageListResult> {
    const ctx = await this.resolveCustomer(userId);
    const conversation = await this.conversations.findByCompanyId(
      ctx.companyId,
    );
    if (!conversation) return { items: [], nextCursor: null };
    return this.listMessages(conversation.id, query);
  }

  /** Send as the customer, opening the thread if this is the first message. */
  async sendAsCustomer(
    userId: string,
    body: string | undefined,
    attachments: Express.Multer.File[] = [],
  ): Promise<SendSupportMessageResult> {
    const ctx = await this.resolveCustomer(userId);
    const conversation = await this.getOrCreate(
      ctx.companyId,
      ctx.companyName,
      userId,
    );
    const { conversation: updated, message } = await this.appendMessage(
      conversation,
      userId,
      SupportAuthorRole.CUSTOMER,
      body,
      ctx.authorName,
      attachments,
    );
    return {
      conversation: this.toConversationDto(updated, 0),
      message: message,
    };
  }

  async markCustomerRead(userId: string): Promise<{ unreadCount: number }> {
    const ctx = await this.resolveCustomer(userId);
    const conversation = await this.conversations.findByCompanyId(
      ctx.companyId,
    );
    if (conversation) {
      await this.conversations.update(conversation.id, {
        customerLastReadAt: new Date(),
      });
    }
    return this.unreadCount(SupportAuthorRole.CUSTOMER, userId);
  }

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

  async listForAgents(
    query: ListConversationsQueryDto,
  ): Promise<SupportConversationListResult> {
    const [rows, count] = await this.conversations.listAll(
      SupportAuthorRole.AGENT,
      query,
    );
    return this.buildListResult(rows, count, SupportAuthorRole.AGENT);
  }

  /**
   * Open (or reuse) the thread with a company so an agent can start chatting.
   * Idempotent — clicking a company that already has a thread just returns it.
   */
  async startWithCompany(companyId: string): Promise<SupportConversationDto> {
    const company = await this.companies.findCompanyById(companyId);
    const existing = await this.conversations.findByCompanyId(companyId);
    const conversation =
      existing ?? (await this.getOrCreate(companyId, company.name, null));

    const unread = await this.messages.unreadCountsByConversation(
      [conversation.id],
      SupportAuthorRole.AGENT,
    );
    const dto = this.toConversationDto(
      conversation,
      unread.get(conversation.id) ?? 0,
    );
    if (!existing) {
      // Surface the new thread in every agent's inbox right away.
      this.gateway.emitConversationUpdated(conversation.companyId, dto);
    }
    return dto;
  }

  async sendAsAgent(
    conversationId: string,
    userId: string,
    body: string | undefined,
    attachments: Express.Multer.File[] = [],
  ): Promise<SupportMessageDto> {
    const conversation = await this.requireConversation(conversationId);
    const { message } = await this.appendMessage(
      conversation,
      userId,
      SupportAuthorRole.AGENT,
      body,
      undefined,
      attachments,
    );
    return message;
  }

  async markAgentRead(
    conversationId: string,
    userId: string,
  ): Promise<{ unreadCount: number }> {
    await this.requireConversation(conversationId);
    await this.conversations.update(conversationId, {
      agentLastReadAt: new Date(),
    });
    return this.unreadCount(SupportAuthorRole.AGENT, userId);
  }

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

  /**
   * One page of a thread's messages, newest page first. Pass `asCustomerUserId`
   * to enforce that the caller's company owns it (portal route); omit for
   * agents, who see every thread.
   */
  async getMessages(
    conversationId: string,
    query: ListMessagesQueryDto = {},
    asCustomerUserId?: string,
  ): Promise<SupportMessageListResult> {
    const conversation = await this.requireConversation(conversationId);
    if (asCustomerUserId) {
      await this.assertCustomerOwns(conversation, asCustomerUserId);
    }
    return this.listMessages(conversationId, query);
  }

  /**
   * May `userId` read the message that a chat attachment hangs off? Backstop for
   * the file-download route, which otherwise streams any file to any
   * authenticated caller who knows its UUID.
   *
   * Backoffice staff see every thread (they work a shared inbox); a portal user
   * sees only their own company's. Fails **closed** — an unresolvable message,
   * conversation, or staff list denies rather than falls through, since the
   * caller uses this to decide whether to hand over raw bytes.
   */
  async canUserAccessMessage(
    messageId: string,
    userId: string,
  ): Promise<boolean> {
    const message = await this.messages.findById(messageId);
    if (!message) return false;
    const conversation = await this.conversations.findById(
      message.conversationId,
    );
    if (!conversation) return false;

    try {
      const staffIds = await this.backoffice.getAllCurrentEmployeeUserIds();
      if (staffIds.includes(userId)) return true;
    } catch {
      // Staff lookup is best-effort for room-joining in the gateway, but here it
      // gates bytes: on failure fall through to the (stricter) company check
      // rather than assuming staff.
    }

    const profile = await this.externalProfiles.findByUserId(userId);
    return Boolean(
      profile?.companyId && profile.companyId === conversation.companyId,
    );
  }

  async unreadCount(
    side: SupportAuthorRole,
    userId: string,
  ): Promise<{ unreadCount: number }> {
    if (side === SupportAuthorRole.CUSTOMER) {
      const ctx = await this.resolveCustomer(userId);
      return {
        unreadCount: await this.messages.countUnreadConversations(
          side,
          ctx.companyId,
        ),
      };
    }
    return { unreadCount: await this.messages.countUnreadConversations(side) };
  }

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

  /**
   * Fetch the company's thread or open it. Two first-messages can race here, so
   * we let the unique index arbitrate and re-read the winner rather than
   * locking — the loser's insert is the only wasted work.
   */
  private async getOrCreate(
    companyId: string,
    companyName: string | null | undefined,
    createdByUserId: string | null,
  ): Promise<SupportConversation> {
    const existing = await this.conversations.findByCompanyId(companyId);
    if (existing) return existing;

    try {
      return await this.conversations.create({
        companyId,
        companyName: companyName ?? null,
        createdByUserId,
      });
    } catch (error) {
      if (
        error instanceof QueryFailedError &&
        (error as QueryFailedError & { code?: string }).code ===
          PG_UNIQUE_VIOLATION
      ) {
        const winner = await this.conversations.findByCompanyId(companyId);
        if (winner) return winner;
      }
      throw error;
    }
  }

  private async listMessages(
    conversationId: string,
    query: ListMessagesQueryDto,
  ): Promise<SupportMessageListResult> {
    const limit = query.limit ?? SUPPORT_MESSAGES_DEFAULT_LIMIT;
    const before = query.before ? decodeMessageCursor(query.before) : undefined;

    // The repo returns newest-first and over-fetches by one to probe for a
    // further page.
    const rows = await this.messages.listByConversation(
      conversationId,
      limit,
      before,
    );
    const hasMore = rows.length > limit;
    const page = hasMore ? rows.slice(0, limit) : rows;

    const oldest = page[page.length - 1];
    const nextCursor =
      hasMore && oldest ? encodeMessageCursor(oldest.id) : null;

    // Flip to oldest-first so the client can prepend a page as one block.
    const items = await this.toMessageDtos([...page].reverse());
    return { items, nextCursor };
  }

  /**
   * Persist a message (plus any attachments), bump the conversation's
   * denormalized fields, emit live.
   *
   * Files are validated *before* the row is written: a rejected upload should
   * leave no message behind, and a half-uploaded batch is worse than none.
   */
  private async appendMessage(
    conversation: SupportConversation,
    userId: string,
    role: SupportAuthorRole,
    body: string | undefined,
    authorName?: string | null,
    attachments: Express.Multer.File[] = [],
  ): Promise<{
    conversation: SupportConversation;
    message: SupportMessageDto;
  }> {
    const text = (body ?? "").trim();
    this.assertSendable(text, attachments);

    const message = await this.messages.create({
      conversationId: conversation.id,
      authorUserId: userId,
      authorRole: role,
      authorName: authorName ?? null,
      // NULL, not "", so "this message has no text" is representable rather than
      // inferred. The DTO flattens it back to "" for rendering.
      body: text || null,
    });

    // The row has to exist before the files, since each one is stored against
    // `resourceId = message.id`. That leaves a window: if a 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: FileRecord[];
    try {
      stored = await Promise.all(
        attachments.map((file) =>
          this.files.upload({
            resourceId: message.id,
            resource: SUPPORT_ATTACHMENT_RESOURCE,
            code: "attachment",
            file,
          }),
        ),
      );
    } catch (error) {
      await this.messages.softDelete(message.id);
      throw error;
    }

    conversation.lastMessageAt = message.createdAt;
    conversation.lastMessagePreview = this.buildPreview(text, stored);
    conversation.lastMessageAuthorRole = role;
    await this.conversations.update(conversation.id, {
      lastMessageAt: conversation.lastMessageAt,
      lastMessagePreview: conversation.lastMessagePreview,
      lastMessageAuthorRole: role,
    });

    const messageDto = await this.toMessageDto(message, stored);
    const dto = this.toConversationDto(conversation, 0);
    this.gateway.emitMessage(conversation.companyId, dto, messageDto);
    return { conversation, message: messageDto };
  }

  /**
   * Guard the chat-specific upload rules. These are tighter than
   * `FilesService.upload`'s own defence-in-depth checks (25MB, wider MIME set),
   * which exist for scanned business documents — chat files are pushed at
   * another human, so the allowlist is narrower and SVG is excluded outright.
   */
  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 the filenames when there's no text. */
  private buildPreview(text: string, attachments: FileRecord[]): 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: SupportConversation[],
    count: number,
    side: SupportAuthorRole,
    companyId?: string,
  ): Promise<SupportConversationListResult> {
    const unreadMap = await this.messages.unreadCountsByConversation(
      rows.map((r) => r.id),
      side,
    );
    const items = rows.map((r) =>
      this.toConversationDto(r, unreadMap.get(r.id) ?? 0),
    );
    const unreadCount = await this.messages.countUnreadConversations(
      side,
      companyId,
    );
    return { items, count, unreadCount };
  }

  private async resolveCustomer(userId: string): Promise<CustomerContext> {
    const profile = await this.externalProfiles.findByUserId(userId);
    if (!profile?.companyId) {
      throw new ForbiddenException(
        "No company profile is linked to this account.",
      );
    }
    const name = [profile.firstName, profile.lastName]
      .filter(Boolean)
      .join(" ")
      .trim();
    return {
      companyId: profile.companyId,
      companyName: profile.company?.name ?? null,
      authorName: name || null,
    };
  }

  private async assertCustomerOwns(
    conversation: SupportConversation,
    userId: string,
  ): Promise<CustomerContext> {
    const ctx = await this.resolveCustomer(userId);
    if (conversation.companyId !== ctx.companyId) {
      throw new ForbiddenException(
        "This conversation belongs to another company.",
      );
    }
    return ctx;
  }

  private async requireConversation(id: string): Promise<SupportConversation> {
    const conversation = await this.conversations.findById(id);
    if (!conversation) {
      throw new NotFoundException("Conversation not found.");
    }
    return conversation;
  }

  private toConversationDto(
    c: SupportConversation,
    unreadCount: number,
  ): SupportConversationDto {
    return {
      id: c.id,
      companyId: c.companyId,
      companyName: c.companyName ?? null,
      createdByUserId: c.createdByUserId ?? null,
      lastMessageAt: c.lastMessageAt
        ? new Date(c.lastMessageAt).toISOString()
        : null,
      lastMessagePreview: c.lastMessagePreview ?? null,
      lastMessageAuthorRole: c.lastMessageAuthorRole ?? null,
      unreadCount,
      createdAt: new Date(c.createdAt).toISOString(),
      updatedAt: new Date(c.updatedAt).toISOString(),
    };
  }

  /** Hydrate + map a page of messages, batching the attachment lookup. */
  private async toMessageDtos(
    rows: SupportMessage[],
  ): Promise<SupportMessageDto[]> {
    if (rows.length === 0) return [];
    const grouped = await this.files.findByResourceIdsGrouped(
      rows.map((r) => r.id),
      SUPPORT_ATTACHMENT_RESOURCE,
    );
    return Promise.all(
      rows.map((r) => this.toMessageDto(r, grouped.get(r.id) ?? [])),
    );
  }

  private async toMessageDto(
    m: SupportMessage,
    attachments: FileRecord[],
  ): Promise<SupportMessageDto> {
    return {
      id: m.id,
      conversationId: m.conversationId,
      authorUserId: m.authorUserId,
      authorRole: m.authorRole,
      authorName: m.authorName ?? null,
      body: m.body ?? "",
      attachments: attachments.map((a) => this.toAttachmentDto(a)),
      createdAt: new Date(m.createdAt).toISOString(),
    };
  }

  /**
   * Where the browser fetches the bytes: the API's own ownership-checked stream
   * route, NOT a presigned MinIO URL.
   *
   * Presigned object URLs are not reachable from the browser in this deployment
   * — the same reason every other file in the app streams through
   * `GET /api/files/:id` rather than a signed URL (see the `fileViewUrl` helper
   * on the web side, and the minio-js port-443 signature quirk noted there). Chat
   * attachments stream through `GET /api/support/attachments/:id`, which runs the
   * same-company / staff ownership check before serving a byte.
   *
   * A root-relative path; the web app prepends its API origin. The `<img>` sends
   * the `auth-token` cookie automatically (same-site across dev ports), which is
   * how the guard authenticates a request that can't carry a bearer header.
   */
  private toAttachmentDto(f: FileRecord): SupportAttachmentDto {
    return {
      id: f.id,
      name: f.name,
      mimeType: f.mimeType,
      size: f.size,
      url: `/api/support/attachments/${f.id}`,
    };
  }
}
