/**
 * Customer namespace (`/customer`).
 *
 * A customer socket connects anonymously (pre-chat form not yet submitted) or
 * with a customer token that pins it to exactly one conversation. Every handler
 * re-checks that binding, so a token for chat A can never touch chat B.
 */
import type { Namespace, Socket } from 'socket.io';
import { z } from 'zod';
import { A2S, C2S, S2C, ROOM, ackErr, ackOk, type Ack } from '../events.js';
import { signCustomerToken, verifyCustomerToken } from '../../auth/jwt.js';
import { enrich } from '../../enrichment/index.js';
import * as convo from '../../modules/conversations/service.js';
import * as messages from '../../modules/messages/service.js';
import { submitCsat } from '../../modules/csat/service.js';
import { enqueue, queuePosition } from '../../modules/conversations/queue.js';
import {
  addConversationSocket,
  removeConversationSocket,
  setTyping,
} from '../presence.js';
import { toAgentLobby, toConversation, toConversationAgents, toCustomer } from '../broadcast.js';
import { registerScreenHandlers } from './screen.js';
import { AppError } from '../../utils/errors.js';
import { logger } from '../../utils/logger.js';

/** Extra state we hang off each customer socket. */
interface CustomerSocketData {
  customerId?: string;
  conversationId?: string;
}

const startSchema = z.object({
  name: z.string().min(1, 'Please enter your name').max(120),
  email: z.string().email('Please enter a valid email').max(200).optional().or(z.literal('')),
  phone: z.string().min(5, 'Please enter a valid phone number').max(40).optional().or(z.literal('')),
  issue: z.string().min(1, 'Please describe your issue').max(4000),
  priority: z.number().int().min(0).max(2).optional(),
  clientHints: z
    .object({
      locale: z.string().max(40).optional(),
      timezone: z.string().max(80).optional(),
      screen: z.object({ w: z.number(), h: z.number(), dpr: z.number() }).optional(),
      pageUrl: z.string().max(2000).optional(),
      referrer: z.string().max(2000).optional(),
    })
    .optional(),
  meta: z.record(z.unknown()).optional(),
});

const sendSchema = z.object({
  body: z.string().max(8000).optional().default(''),
  clientMsgId: z.string().min(1).max(80),
  attachmentId: z.string().uuid().optional().nullable(),
});

/** Wraps a handler so any thrown AppError becomes a clean ack instead of a crash. */
function guard<T>(ack: unknown, fn: () => Promise<T>) {
  const respond = typeof ack === 'function' ? (ack as (a: Ack) => void) : () => undefined;
  return fn()
    .then((data) => respond(ackOk(data)))
    .catch((err) => {
      if (err instanceof AppError) return respond(ackErr(err.code, err.message));
      logger.error('customer socket handler failed', { err: (err as Error).message });
      respond(ackErr('internal_error', 'Something went wrong. Please try again.'));
    });
}

export function registerCustomerNamespace(ns: Namespace) {
  /**
   * Optional auth: a token is only required to *resume*. Starting a chat is
   * anonymous by design - the pre-chat form is what identifies the person.
   */
  ns.use((socket, next) => {
    const token = socket.handshake.auth?.token as string | undefined;
    if (!token) return next();
    try {
      const claims = verifyCustomerToken(token);
      const data = socket.data as CustomerSocketData;
      data.customerId = claims.sub;
      data.conversationId = claims.conversationId;
      next();
    } catch {
      // An expired token shouldn't lock the widget out - fall back to anonymous.
      next();
    }
  });

  ns.on('connection', (socket: Socket) => {
    const data = socket.data as CustomerSocketData;
    logger.debug('customer socket connected', { id: socket.id, conversationId: data.conversationId });

    /** Only true for the conversation this socket is bound to. */
    const owns = async (conversationId: string) =>
      Boolean(data.conversationId) && data.conversationId === conversationId;

    /* -------------------- start (pre-chat form) -------------------- */
    socket.on(C2S.chatStart, (payload, ack) =>
      guard(ack, async () => {
        const parsed = startSchema.safeParse(payload);
        if (!parsed.success) {
          const first = parsed.error.errors[0];
          throw new AppError(400, first?.message ?? 'Invalid form', 'validation_error', parsed.error.flatten());
        }
        const input = parsed.data;

        // Enrichment runs server-side from the handshake: the customer cannot
        // spoof their own IP or User-Agent into the agent's panel.
        const enrichment = await enrich({
          headers: socket.handshake.headers as Record<string, any>,
          socket: { remoteAddress: socket.handshake.address },
          clientHints: input.clientHints,
        });

        const conversation = await convo.startChat({
          name: input.name,
          email: input.email || null,
          phone: input.phone || null,
          issue: input.issue,
          priority: input.priority,
          meta: input.meta,
          enrichment,
        });

        // Bind this socket to the new conversation.
        data.customerId = conversation.customer.id;
        data.conversationId = conversation.id;
        await socket.join(ROOM.conversation(conversation.id));
        await addConversationSocket(conversation.id, socket.id);

        await enqueue(conversation);
        const position = await queuePosition(conversation.id);

        // The agent dashboard gets the fully enriched profile *before* any
        // conversation happens - that is the whole point of the pre-chat form.
        toAgentLobby(S2C.queueNew, conversation);

        const token = signCustomerToken({
          sub: conversation.customer.id,
          conversationId: conversation.id,
        });

        const history = await messages.listRecentMessages(conversation.id, 50);

        return {
          token,
          conversation: publicConversation(conversation),
          messages: history,
          queuePosition: position,
        };
      }),
    );

    /* -------------------- resume after reload -------------------- */
    socket.on(C2S.chatResume, (payload, ack) =>
      guard(ack, async () => {
        const conversationId = String(payload?.conversationId ?? data.conversationId ?? '');
        if (!conversationId || !(await owns(conversationId))) {
          throw new AppError(403, 'This chat session has expired', 'forbidden');
        }
        const conversation = await convo.getConversation(conversationId);
        await socket.join(ROOM.conversation(conversationId));
        await addConversationSocket(conversationId, socket.id);

        const [history, position] = await Promise.all([
          messages.listRecentMessages(conversationId, 200),
          queuePosition(conversationId),
        ]);

        return {
          conversation: publicConversation(conversation),
          messages: history,
          queuePosition: position,
        };
      }),
    );

    /* -------------------- send a message -------------------- */
    socket.on(C2S.messageSend, (payload, ack) =>
      guard(ack, async () => {
        const conversationId = data.conversationId;
        if (!conversationId) throw new AppError(403, 'Start a chat first', 'no_conversation');

        const parsed = sendSchema.safeParse(payload);
        if (!parsed.success) throw new AppError(400, 'Invalid message', 'validation_error');

        const conversation = await convo.getConversation(conversationId);
        if (conversation.status === 'closed') {
          throw new AppError(409, 'This chat has been closed', 'chat_closed');
        }

        // Persist first, broadcast second: a delivered message is always a
        // stored message, so reconnect-resync can never lose it.
        const message = await messages.createMessage({
          conversationId,
          senderType: 'customer',
          senderId: conversation.customer.id,
          body: parsed.data.body,
          attachmentId: parsed.data.attachmentId ?? null,
          clientMsgId: parsed.data.clientMsgId,
        });

        await setTyping(conversationId, conversation.customer.id, false);
        toConversation(conversationId, S2C.messageNew, message);
        // Keeps the agent's sidebar preview + unread badge current.
        toAgentLobby(S2C.conversationUpdated, await convo.getConversation(conversationId));

        return message;
      }),
    );

    /* -------------------- typing -------------------- */
    socket.on(C2S.typing, async (payload) => {
      const conversationId = data.conversationId;
      if (!conversationId || !data.customerId) return;
      const isTyping = Boolean(payload?.isTyping);
      await setTyping(conversationId, data.customerId, isTyping);
      toConversationAgents(conversationId, S2C.typing, {
        conversationId,
        actor: 'customer',
        actorId: data.customerId,
        isTyping,
      });
    });

    /* -------------------- read receipts -------------------- */
    socket.on(C2S.read, (payload, ack) =>
      guard(ack, async () => {
        const conversationId = data.conversationId;
        if (!conversationId) throw new AppError(403, 'No active chat', 'no_conversation');
        const seq = Number(payload?.seq ?? 0);
        const applied = await messages.markRead(conversationId, 'customer', seq);
        toConversationAgents(conversationId, S2C.read, {
          conversationId,
          reader: 'customer',
          seq: applied,
        });
        return { seq: applied };
      }),
    );

    /* -------------------- reconnect resync -------------------- */
    socket.on(C2S.historySync, (payload, ack) =>
      guard(ack, async () => {
        const conversationId = data.conversationId;
        if (!conversationId) throw new AppError(403, 'No active chat', 'no_conversation');
        const sinceSeq = Number(payload?.sinceSeq ?? 0);
        const batch = await messages.listMessages(conversationId, { sinceSeq, limit: 500 });
        return { messages: batch, latestSeq: await messages.maxSeq(conversationId) };
      }),
    );

    /* -------------------- end chat + CSAT -------------------- */
    socket.on(C2S.chatEnd, (_payload, ack) =>
      guard(ack, async () => {
        const conversationId = data.conversationId;
        if (!conversationId) throw new AppError(403, 'No active chat', 'no_conversation');
        const conversation = await convo.closeConversation(conversationId, 'closed_by_customer');
        await messages.systemMessage(conversationId, 'The customer ended this chat.');
        toConversation(conversationId, S2C.conversationClosed, conversation);
        toAgentLobby(S2C.conversationUpdated, conversation);
        return publicConversation(conversation);
      }),
    );

    socket.on(C2S.csatSubmit, (payload, ack) =>
      guard(ack, async () => {
        const conversationId = data.conversationId;
        if (!conversationId) throw new AppError(403, 'No active chat', 'no_conversation');
        const rating = Number(payload?.rating);
        const result = await submitCsat(conversationId, rating, payload?.comment ?? null);
        toConversationAgents(conversationId, S2C.csatReceived, result);
        toAgentLobby(S2C.csatReceived, result);
        return result;
      }),
    );

    /* -------------------- screen sharing -------------------- */
    registerScreenHandlers(socket, 'customer', owns);

    /* -------------------- disconnect -------------------- */
    socket.on('disconnect', async () => {
      const conversationId = data.conversationId;
      if (!conversationId) return;
      await removeConversationSocket(conversationId, socket.id);
      if (data.customerId) await setTyping(conversationId, data.customerId, false);
      toConversationAgents(conversationId, S2C.presenceUpdate, {
        conversationId,
        actor: 'customer',
        online: false,
      });
    });

    // Announce the customer as present to any agent already watching.
    if (data.conversationId) {
      toConversationAgents(data.conversationId, S2C.presenceUpdate, {
        conversationId: data.conversationId,
        actor: 'customer',
        online: true,
      });
    }
  });
}

/**
 * Strips agent-only fields before anything reaches a customer socket.
 * Enrichment, internal notes and unread counters stay server-side.
 */
function publicConversation(c: Awaited<ReturnType<typeof convo.getConversation>>) {
  return {
    id: c.id,
    status: c.status,
    subject: c.subject,
    agent: c.agent,
    customer: c.customer,
    customerLastReadSeq: c.customerLastReadSeq,
    agentLastReadSeq: c.agentLastReadSeq,
    createdAt: c.queuedAt,
  };
}

export { A2S };
