import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { PrismaService } from 'src/prisma/prisma.service';
import { PedidosService } from 'src/pedidos/pedidos.service';
import { EcommerceNotificationsService } from '../notifications/ecommerce-notifications.service';
import { EcommerceStockReservaService } from '../stock/ecommerce-stock-reserva.service';
import { leerConfigPublicada } from '../config-publicada';
import {
  effectiveWorkflowState,
  nextWorkflowStates,
  normalizeWorkflowConfig,
  resolveDeliveryType,
  assertDeliveryState,
  canReportIncidencia,
} from './ecommerce-workflow';

/** Estados del pedido ERP que ya no admiten una cancelación operativa directa. */
const PEDIDO_ERP_FACTURADO = 'facturado';

/** Nombre del cliente genérico para compras de invitados (Opción A). */
const CLIENTE_GENERICO = 'Consumidor Final - Ecommerce';

@Injectable()
export class EcommercePedidosService {
  private readonly logger = new Logger(EcommercePedidosService.name);

  constructor(
    private readonly prisma: PrismaService,
    private readonly pedidosService: PedidosService,
    private readonly notifications: EcommerceNotificationsService,
    private readonly stockReservas: EcommerceStockReservaService,
  ) {}

  /**
   * Bandeja de pedidos ecommerce (sesiones de checkout), paginada en el server.
   * La búsqueda cruza código (insensible a mayúsculas) y nombre/teléfono del
   * snapshot del cliente vía ILIKE sobre el JSONB.
   */
  async list(
    empresaId: string,
    filtros: { estado?: string; q?: string; page?: number; limit?: number },
  ) {
    const page = Math.max(1, Number(filtros.page) || 1);
    const limit = Math.min(100, Math.max(1, Number(filtros.limit) || 20));
    const q = filtros.q?.trim();

    const where: any = { empresa_id: empresaId };
    if (filtros.estado) where.estado = filtros.estado;
    if (q) {
      where.OR = [
        { codigo: { contains: q, mode: 'insensitive' } },
        ...(await this.sessionIdsByCustomer(empresaId, q)),
      ];
    }

    const [sesiones, total, config] = await Promise.all([
      this.prisma.ecommerce_checkout_session.findMany({
        where,
        orderBy: { created_at: 'desc' },
        skip: (page - 1) * limit,
        take: limit,
      }),
      this.prisma.ecommerce_checkout_session.count({ where }),
      this.prisma.ecommerce_config.findUnique({
        where: { empresa_id: empresaId },
        select: { workflow_config: true },
      }),
    ]);

    const items = await this.attachFacturas(
      empresaId,
      sesiones.map((s) => this.serialize(s, false, config?.workflow_config)),
    );

    // Conteo por estado para los tabs de la bandeja (sobre TODOS los pedidos,
    // sin filtro de página ni búsqueda: los tabs muestran el panorama global).
    const counts = await this.prisma.ecommerce_checkout_session.groupBy({
      by: ['estado'],
      where: { empresa_id: empresaId },
      _count: { _all: true },
    });
    const resumen: Record<string, number> = {};
    for (const c of counts) resumen[c.estado] = c._count._all;

    return { items, resumen, total, page, limit };
  }

  /**
   * Export sin paginar (tope 2000) para descarga en Excel del panel. Reusa los
   * mismos filtros que la bandeja (estado, búsqueda) + rango de fechas, y
   * devuelve filas ya aplanadas (no snapshots anidados) listas para la planilla.
   */
  async exportar(empresaId: string, filtros: { estado?: string; q?: string; fecha_desde?: string; fecha_hasta?: string }) {
    const q = filtros.q?.trim();
    const where: any = { empresa_id: empresaId };
    if (filtros.estado) where.estado = filtros.estado;
    if (filtros.fecha_desde || filtros.fecha_hasta) {
      where.created_at = {};
      if (filtros.fecha_desde) where.created_at.gte = new Date(`${filtros.fecha_desde}T00:00:00`);
      if (filtros.fecha_hasta) where.created_at.lte = new Date(`${filtros.fecha_hasta}T23:59:59`);
    }
    if (q) {
      where.OR = [
        { codigo: { contains: q, mode: 'insensitive' } },
        ...(await this.sessionIdsByCustomer(empresaId, q)),
      ];
    }

    const sesiones = await this.prisma.ecommerce_checkout_session.findMany({
      where,
      orderBy: { created_at: 'desc' },
      take: 2000,
    });

    return sesiones.map((s) => {
      const deliveryType = resolveDeliveryType(s.delivery_snapshot);
      const estado = effectiveWorkflowState(s.estado, deliveryType);
      const customer = (s.customer_snapshot ?? {}) as { name?: string; phone?: string; email?: string };
      const totals = (s.totals_snapshot ?? {}) as { subtotal?: number; deliveryCost?: number; total?: number; paymentMethodName?: string };
      return {
        codigo: s.codigo,
        fecha: s.created_at,
        estado,
        tipo_entrega: deliveryType === 'pickup' ? 'Retiro' : 'Delivery',
        metodo_pago: s.metodo_pago ?? '',
        medio_pago_nombre: totals.paymentMethodName ?? null,
        cliente: customer.name ?? '',
        telefono: customer.phone ?? '',
        email: customer.email ?? '',
        subtotal: Number(totals.subtotal ?? 0),
        envio: Number(totals.deliveryCost ?? 0),
        total: Number(totals.total ?? 0),
      };
    });
  }

  /**
   * IDs de sesiones cuyo cliente (nombre/teléfono en el snapshot JSONB) matchea
   * la búsqueda, como cláusula OR extra. Se resuelve por SQL crudo porque Prisma
   * no soporta ILIKE sobre campos JSON.
   */
  private async sessionIdsByCustomer(empresaId: string, q: string): Promise<Array<{ id: { in: string[] } }>> {
    const rows = await this.prisma.$queryRaw<{ id: string }[]>`
      SELECT id FROM ecommerce_checkout_session
      WHERE empresa_id = ${empresaId}::uuid
        AND (customer_snapshot->>'name' ILIKE ${'%' + q + '%'}
          OR customer_snapshot->>'phone' ILIKE ${'%' + q + '%'})
      LIMIT 500
    `;
    return rows.length > 0 ? [{ id: { in: rows.map((r) => r.id) } }] : [];
  }

  async detail(empresaId: string, id: string) {
    const [s, config] = await Promise.all([
      this.prisma.ecommerce_checkout_session.findFirst({
        where: { id, empresa_id: empresaId },
      }),
      this.prisma.ecommerce_config.findUnique({
        where: { empresa_id: empresaId },
        select: { workflow_config: true },
      }),
    ]);
    if (!s) throw new NotFoundException('Pedido no encontrado.');

    // Bitácora del pedido. Se venía escribiendo en cada transición pero no la
    // devolvía ningún endpoint, así que el operador no tenía forma de ver quién
    // movió el pedido ni con qué nota. Acá sí van `metadata` y el autor: es la
    // ficha interna, no la vista del comprador.
    const eventos = await this.prisma.ecommerce_pedido_evento.findMany({
      where: { empresa_id: empresaId, checkout_session_id: id },
      orderBy: { created_at: 'asc' },
    });

    const autorIds = [...new Set(eventos.map((e) => e.created_by).filter((v): v is string => !!v))];
    const autores = autorIds.length
      ? await this.prisma.usuario.findMany({
          where: { id: { in: autorIds } },
          select: { id: true, nombres: true, apellidos: true, username: true },
        })
      : [];
    const autorPorId = new Map(
      autores.map((u) => [
        u.id,
        [u.nombres, u.apellidos].filter(Boolean).join(' ').trim() || u.username || null,
      ]),
    );

    // Reserva de stock. Se venía tomando desde el checkout pero no se mostraba en
    // ningún lado: el operador veía "Creado y reservado" sin poder comprobar qué
    // se reservó, de qué depósito ni hasta cuándo.
    const reservas = await this.prisma.$queryRawUnsafe<
      {
        producto: string | null;
        cod_producto: string | null;
        deposito: string | null;
        cantidad: unknown;
        liberada: boolean;
        expira_at: Date | null;
      }[]
    >(
      `SELECT p.descripcion AS producto, p.cod_producto, d.descripcion AS deposito,
              r.cantidad, r.liberada, r.expira_at
         FROM ecommerce_stock_reserva r
         LEFT JOIN productos p ON p.id = r.producto_id
         LEFT JOIN depositos d ON d.id = r.deposito_id
        WHERE r.empresa_id = $1::uuid AND r.session_id = $2::uuid
        ORDER BY p.descripcion`,
      empresaId,
      id,
    );

    return {
      ...(await this.attachFacturas(empresaId, [this.serialize(s, true, config?.workflow_config)]))[0],
      reservas: reservas.map((r) => ({
        producto: r.producto,
        sku: r.cod_producto,
        deposito: r.deposito,
        cantidad: Number(r.cantidad ?? 0),
        liberada: r.liberada,
        expiraAt: r.expira_at,
      })),
      eventos: eventos.map((e) => ({
        id: e.id,
        tipo: e.tipo,
        estadoAnterior: e.estado_anterior,
        estadoNuevo: e.estado_nuevo,
        notas: (e.metadata as any)?.notas ?? null,
        autor: e.created_by ? (autorPorId.get(e.created_by) ?? null) : null,
        fecha: e.created_at,
      })),
    };
  }

  /** Cambia el estado operativo validando la transición. */
  async changeEstado(
    empresaId: string,
    id: string,
    nuevoEstado: string,
    notas: string | undefined,
    userId: string | undefined,
  ) {
    const s = await this.prisma.ecommerce_checkout_session.findFirst({
      where: { id, empresa_id: empresaId },
      select: {
        id: true,
        estado: true,
        pedido_id: true,
        delivery_snapshot: true,
        workflow_snapshot: true,
      },
    });
    if (!s) throw new NotFoundException('Pedido no encontrado.');

    const config = s.workflow_snapshot
      ? null
      : await this.prisma.ecommerce_config.findUnique({
          where: { empresa_id: empresaId },
          select: { workflow_config: true },
        });
    const workflow = normalizeWorkflowConfig(s.workflow_snapshot ?? config?.workflow_config);
    const deliveryType = resolveDeliveryType(s.delivery_snapshot);
    const permitidos = nextWorkflowStates(s.estado, deliveryType, workflow);
    if (!permitidos.includes(nuevoEstado)) {
      throw new BadRequestException(
        `No se puede pasar de "${s.estado}" a "${nuevoEstado}". Permitidos: ${permitidos.join(', ') || 'ninguno'}.`,
      );
    }
    try {
      assertDeliveryState(nuevoEstado, deliveryType);
    } catch (error) {
      throw new BadRequestException((error as Error).message);
    }

    // G1 — No avanzar la preparación sin pedido ERP ni reserva de stock. La
    // creación del pedido ERP en `pago_confirmado` es best-effort: si falló, el
    // pedido queda sin `pedido_id` y no debe prepararse mercadería que no está
    // reservada ni existe en el ERP. Confirmar pago y cancelar quedan exentos.
    const esAvanceOperativo = nuevoEstado !== 'cancelado' && nuevoEstado !== 'pago_confirmado';
    if (esAvanceOperativo && !s.pedido_id) {
      throw new BadRequestException(
        'El pedido todavía no tiene pedido ERP ni stock reservado. Creá el pedido ERP ' +
          '(o reintentá el pago) antes de avanzar la preparación.',
      );
    }

    // G2 — No cancelar directo un pedido ya facturado. Se valida ANTES de tocar
    // la sesión: si el pedido ERP está facturado, `pedidosService.cancelar`
    // rechazaría, pero la sesión ya habría quedado en "cancelado" (inconsistente
    // con una factura vigente). La reversión fiscal va por nota de crédito.
    if (nuevoEstado === 'cancelado' && s.pedido_id) {
      const pedidoErp = await this.prisma.pedidos.findUnique({
        where: { id: s.pedido_id },
        select: { estado: true },
      });
      if (pedidoErp?.estado === PEDIDO_ERP_FACTURADO) {
        throw new BadRequestException(
          'El pedido ya fue facturado. No se puede cancelar directo: emití una nota de ' +
            'crédito o anulación fiscal desde Ventas para revertirlo.',
        );
      }
    }

    const data: any = { estado: nuevoEstado, updated_at: new Date() };
    if (notas !== undefined) data.notas = notas;
    if (nuevoEstado === 'pago_confirmado') {
      data.confirmado_por = userId ?? null;
      data.confirmado_at = new Date();
    }
    if (nuevoEstado === 'en_verificacion') {
      data.operador_asignado_id = userId ?? null;
      data.asignado_at = new Date();
    }

    await this.prisma.$transaction(async (tx) => {
      const changed = await tx.ecommerce_checkout_session.updateMany({
        where: { id, empresa_id: empresaId, estado: s.estado },
        data,
      });
      if (changed.count !== 1) {
        throw new ConflictException('Otro operador ya tomó o actualizó este pedido. Recargá la ficha.');
      }
      await tx.ecommerce_pedido_evento.create({
        data: {
          empresa_id: empresaId,
          checkout_session_id: id,
          pedido_id: s.pedido_id,
          tipo: nuevoEstado === 'en_verificacion' ? 'asignacion' : 'estado',
          estado_anterior: effectiveWorkflowState(s.estado, deliveryType),
          estado_nuevo: nuevoEstado,
          metadata: { deliveryType, notas: notas ?? null },
          created_by: userId ?? null,
        },
      });
    });

    // Disparar notificación según el estado (best-effort, no bloquea la respuesta).
    if (nuevoEstado === 'en_camino') {
      this.notifications.onOrderShipped(empresaId, id).catch(() => undefined);
    } else if (nuevoEstado === 'cancelado') {
      this.notifications.onOrderCancelled(empresaId, id).catch(() => undefined);
    }

    let pedidoAviso: string | undefined;

    // Al confirmar el pago, crear el pedido ERP + reservar stock (best-effort).
    if (nuevoEstado === 'pago_confirmado') {
      try {
        const pedidoId = await this.crearPedidoErp(empresaId, id, userId);
        pedidoAviso = `Pedido ERP creado y stock reservado (${pedidoId.slice(0, 8)}).`;
      } catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        this.logger.warn(`No se pudo crear el pedido ERP para sesión ${id}: ${msg}`);
        await this.prisma.ecommerce_checkout_session.update({
          where: { id },
          data: { notas: `[Pendiente ERP] ${msg}` },
        });
        pedidoAviso = `Pago confirmado, pero el pedido ERP no se pudo crear: ${msg}. Reintentá desde "Crear pedido ERP".`;
      }
    }

    // Al cancelar, liberar el stock reservado por el pedido ERP vinculado. Sin
    // esto la reserva queda tomada para siempre y degrada el inventario.
    if (nuevoEstado === 'cancelado') {
      pedidoAviso = await this.cancelarPedidoErpVinculado(empresaId, id, userId, notas);
    }

    const updated = await this.prisma.ecommerce_checkout_session.findUnique({ where: { id } });
    return { ...(await this.attachFacturas(empresaId, [this.serialize(updated, true, workflow)]))[0], aviso: pedidoAviso };
  }

  /**
   * G3 — Marca un pedido "con incidencia": lo saca del flujo normal y lo congela.
   * No libera stock ni toca el pedido ERP (el pedido sigue vivo). Desde acá sólo
   * se puede reencauzar a preparación o cancelar, y eso lo controla el permiso de
   * supervisor del endpoint. Requiere un motivo.
   */
  async marcarIncidencia(
    empresaId: string,
    id: string,
    notas: string | undefined,
    userId: string | undefined,
  ) {
    const s = await this.prisma.ecommerce_checkout_session.findFirst({
      where: { id, empresa_id: empresaId },
      select: { id: true, estado: true, pedido_id: true, delivery_snapshot: true, workflow_snapshot: true },
    });
    if (!s) throw new NotFoundException('Pedido no encontrado.');

    if (!notas || !notas.trim()) {
      throw new BadRequestException('Indicá el motivo de la incidencia.');
    }
    const deliveryType = resolveDeliveryType(s.delivery_snapshot);
    if (!canReportIncidencia(s.estado, deliveryType)) {
      throw new BadRequestException(
        `No se puede reportar una incidencia sobre un pedido en estado "${s.estado}".`,
      );
    }

    const estadoAnterior = effectiveWorkflowState(s.estado, deliveryType);
    await this.prisma.$transaction(async (tx) => {
      const changed = await tx.ecommerce_checkout_session.updateMany({
        where: { id, empresa_id: empresaId, estado: s.estado },
        data: { estado: 'incidencia', notas, updated_at: new Date() },
      });
      if (changed.count !== 1) {
        throw new ConflictException('Otro operador ya actualizó este pedido. Recargá la ficha.');
      }
      await tx.ecommerce_pedido_evento.create({
        data: {
          empresa_id: empresaId,
          checkout_session_id: id,
          pedido_id: s.pedido_id,
          tipo: 'incidencia',
          estado_anterior: estadoAnterior,
          estado_nuevo: 'incidencia',
          metadata: { deliveryType, notas },
          created_by: userId ?? null,
        },
      });
    });

    const config = s.workflow_snapshot
      ? null
      : await this.prisma.ecommerce_config.findUnique({
          where: { empresa_id: empresaId },
          select: { workflow_config: true },
        });
    const updated = await this.prisma.ecommerce_checkout_session.findUnique({ where: { id } });
    return (await this.attachFacturas(empresaId, [this.serialize(updated, true, s.workflow_snapshot ?? config?.workflow_config)]))[0];
  }

  /**
   * Cancela el pedido ERP vinculado a la sesión (si existe) para liberar el
   * stock reservado. Best-effort: si falla, se deja constancia en `notas` y el
   * operador puede cancelar el pedido a mano desde el módulo Pedidos.
   */
  private async cancelarPedidoErpVinculado(
    empresaId: string,
    sessionId: string,
    userId: string | undefined,
    motivo?: string,
  ): Promise<string | undefined> {
    const s = await this.prisma.ecommerce_checkout_session.findFirst({
      where: { id: sessionId, empresa_id: empresaId },
      select: { pedido_id: true, codigo: true },
    });

    // Si el pago nunca se confirmó no hay pedido ERP, pero sí puede haber una
    // reserva pre-pago (Fase 7.5) tomada en el checkout: hay que soltarla.
    if (!s?.pedido_id) {
      const liberadas = await this.stockReservas.liberar(sessionId);
      return liberadas > 0 ? 'Reserva de stock liberada.' : undefined;
    }

    try {
      await this.pedidosService.cancelar(
        s.pedido_id,
        empresaId,
        userId ?? '',
        motivo || `Pedido ecommerce ${s.codigo} cancelado`,
      );
      this.logger.log(`Sesión ${s.codigo}: pedido ERP cancelado, stock liberado.`);
      return 'Pedido ERP cancelado y stock liberado.';
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      this.logger.warn(`No se pudo cancelar el pedido ERP de la sesión ${sessionId}: ${msg}`);
      await this.prisma.ecommerce_checkout_session.update({
        where: { id: sessionId },
        data: { notas: `[Stock sin liberar] ${msg}` },
      });
      return `Pedido cancelado, pero el pedido ERP no pudo cancelarse: ${msg}. Cancelalo a mano en Pedidos para liberar el stock.`;
    }
  }

  /**
   * Confirma el pago de una sesión desde el gateway (webhook o sync lazy del
   * seguimiento). Hace lo MISMO que la confirmación manual del operador: marca
   * `pago_confirmado` y crea el pedido ERP reservando stock.
   *
   * Antes esto se hacía con un UPDATE crudo que sólo cambiaba el estado: el
   * cliente pagaba online, el pedido figuraba confirmado y nunca se creaba el
   * pedido ERP ni se reservaba stock (se podía vender dos veces lo mismo).
   *
   * Idempotente: si la sesión ya no está en `pendiente_pago`, no hace nada.
   */
  async confirmarPagoDesdeGateway(empresaId: string, sessionId: string): Promise<void> {
    const s = await this.prisma.ecommerce_checkout_session.findFirst({
      where: { id: sessionId, empresa_id: empresaId },
      select: { id: true, estado: true, codigo: true },
    });
    if (!s || s.estado !== 'pendiente_pago') return;

    try {
      const result = await this.changeEstado(
        empresaId,
        s.id,
        'pago_confirmado',
        'Pago confirmado por gateway.',
        undefined,
      );
      this.logger.log(
        `Sesión ${s.codigo}: pago confirmado por gateway → pedido ERP ${result.pedido_id ?? 'pendiente'}.`,
      );
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      this.logger.warn(`Sesión ${s.codigo}: no se pudo confirmar el pago del gateway: ${msg}`);
    }
  }

  /**
   * Crea el pedido en el ERP a partir de la sesión de checkout, usando el
   * cliente genérico de invitados y los defaults (sucursal/depósito/lista) de
   * la config. Luego confirma el pedido para reservar stock. Idempotente: si la
   * sesión ya tiene pedido_id, no crea otro.
   */
  async crearPedidoErp(empresaId: string, sessionId: string, userId: string | undefined): Promise<string> {
    const session = await this.prisma.ecommerce_checkout_session.findFirst({
      where: { id: sessionId, empresa_id: empresaId },
    });
    if (!session) throw new NotFoundException('Pedido no encontrado.');

    // Idempotencia / reanudación. Si ya hay un pedido vinculado puede estar:
    //  - confirmado → nada que hacer.
    //  - en borrador → `confirmar()` falló antes (típicamente por stock); se
    //    reintenta sobre ESE pedido en vez de crear otro. Antes se creaba un
    //    borrador nuevo en cada reintento porque el vínculo se guardaba recién
    //    después de confirmar.
    if (session.pedido_id) {
      const pedido = await this.prisma.pedidos.findUnique({
        where: { id: session.pedido_id },
        select: { id: true, estado: true },
      });
      if (pedido) {
        if (pedido.estado === 'borrador') {
          // La reserva pre-pago (Fase 7.5) debe soltarse antes: `confirmar()`
          // vuelve a reservar el mismo stock a nombre del pedido ERP.
          await this.stockReservas.liberar(sessionId);
          await this.pedidosService.confirmar(pedido.id, empresaId, userId ?? '');
        }
        return pedido.id;
      }
      // El pedido vinculado ya no existe: se cae y se crea uno nuevo.
    }

    const items = (session.items_snapshot as any[]) ?? [];
    if (items.length === 0) throw new BadRequestException('La sesión no tiene items.');

    // Sucursal, depósito y lista de la versión publicada: un borrador sin publicar
    // no puede cambiar dónde se reserva el stock de un pedido real.
    const config = await leerConfigPublicada(this.prisma, empresaId, [
      'sucursal_id',
      'deposito_id',
      'lista_precios_id',
      'checkout_config',
    ] as const);

    const customer = (session.customer_snapshot as any) ?? {};
    // A nombre del comprador (el checkout ya lo dio de alta en el ERP con su RUC o CI):
    // si cargó su RUC, la factura tiene que salir a su nombre. Sin documento, o si no se
    // encuentra, consumidor final.
    const clienteId = (await this.resolveClienteDelComprador(empresaId, customer)) ?? (await this.resolveClienteGenerico(empresaId));
    const delivery = (session.delivery_snapshot as any) ?? {};

    // El pedido ERP tiene que sumar lo mismo que pagó el comprador. Antes llevaba sólo
    // el precio de cada producto: se perdían el descuento por medio de pago (se facturaba
    // de más) y el costo de envío (no se facturaba).
    const totals = (session.totals_snapshot as any) ?? {};
    const subtotal = Number(totals.subtotal ?? 0);
    const descuentoPago = Number(totals.paymentDiscount ?? 0);
    const pctDescuento = subtotal > 0 && descuentoPago > 0 ? Math.round((descuentoPago / subtotal) * 1e6) / 1e4 : 0;
    const costoEnvio = Number(totals.deliveryCost ?? 0);
    const envioProductoId: string | undefined = config?.checkout_config?.envioProductoId || undefined;
    const envioSinFacturar = costoEnvio > 0 && !envioProductoId;

    const dto: any = {
      cliente_id: clienteId,
      sucursal_id: config?.sucursal_id ?? undefined,
      deposito_id: config?.deposito_id ?? undefined,
      lista_precios_id: config?.lista_precios_id ?? undefined,
      referencia_interna: session.codigo,
      observaciones:
        `Pedido ecommerce ${session.codigo}. ` +
        `Cliente: ${customer.name ?? '-'} | Tel: ${customer.phone ?? '-'} | Email: ${customer.email ?? '-'}. ` +
        `Entrega: ${delivery.methodName || (delivery.type === 'pickup' ? 'Retiro en tienda' : 'Delivery')}` +
        (delivery.address ? ` - ${delivery.address}` : '') +
        (descuentoPago > 0 ? `. Descuento ${totals.paymentMethodName ?? 'por medio de pago'}: ${pctDescuento}%` : '') +
        (envioSinFacturar
          ? `. ATENCIÓN: envío cobrado ${costoEnvio} sin producto de envío configurado; agregalo a la factura.`
          : ''),
      items: [
        ...items.map((l) => ({
          producto_id: l.id,
          cantidad: l.quantity,
          precio_negociado: l.unitPrice,
          ...(pctDescuento > 0 ? { descuento_porcentaje: pctDescuento } : {}),
        })),
        ...(costoEnvio > 0 && envioProductoId
          ? [{ producto_id: envioProductoId, cantidad: 1, precio_negociado: costoEnvio, observacion: delivery.methodName ?? 'Envío' }]
          : []),
      ],
    };

    // Crear (estado borrador) y confirmar (reserva stock + numero_pedido)
    const created = await this.pedidosService.create(dto, empresaId, userId ?? '');
    const pedidoId = (created as any)?.data?.id;
    if (!pedidoId) throw new BadRequestException('No se pudo crear el pedido.');

    // Vincular ANTES de confirmar: si `confirmar()` falla (p. ej. sin stock), el
    // borrador queda igualmente asociado a la sesión y el reintento lo reanuda,
    // en vez de dejarlo huérfano y crear otro.
    await this.prisma.ecommerce_checkout_session.update({
      where: { id: sessionId },
      data: { pedido_id: pedidoId, updated_at: new Date() },
    });

    // Traspaso de la reserva pre-pago (Fase 7.5) al pedido ERP: se libera la
    // del checkout y `confirmar()` reserva el mismo stock a nombre del pedido.
    // Si no se liberara primero, la misma mercadería quedaría reservada dos veces.
    await this.stockReservas.liberar(sessionId);
    await this.pedidosService.confirmar(pedidoId, empresaId, userId ?? '');

    this.logger.log(`Sesión ${session.codigo} → pedido ERP ${pedidoId} confirmado`);
    return pedidoId;
  }

  /** Busca o crea el cliente genérico de invitados (persona + cliente, tipo B2C). */
  /** Cliente del ERP del comprador, buscado por el RUC o CI que cargó en el checkout. */
  private async resolveClienteDelComprador(
    empresaId: string,
    customer: { ruc?: string; consultadoSIFEN?: boolean },
  ): Promise<string | null> {
    const doc = customer.ruc?.trim();
    if (!doc) return null;
    const persona = await this.prisma.personas.findFirst({
      where: {
        empresa_id: empresaId,
        deleted: false,
        OR: customer.consultadoSIFEN ? [{ ruc: doc }, { nro_documento: doc }] : [{ nro_documento: doc }, { ruc: doc }],
      },
      select: { id: true },
    });
    if (!persona) return null;
    const cliente = await this.prisma.clientes.findFirst({
      where: { persona_id: persona.id, deleted: false },
      select: { id: true },
    });
    return cliente?.id ?? null;
  }

  /**
   * Consumidor final de la tienda, con los datos que SIFEN exige a un receptor sin
   * documento (como el cliente «Sin nombre» que se crea con cada empresa): no
   * contribuyente, documento innominado «0», Paraguay. Antes se creaba sólo con la
   * razón social y la factura se rechazaba: «La naturaleza del receptor no está
   * definida». Si ya existe sin esos datos, se completan.
   */
  private async resolveClienteGenerico(empresaId: string): Promise<string> {
    const [naturaleza, innominado, paraguay, tipoB2C] = await Promise.all([
      this.prisma.naturaleza_receptor.findFirst({ where: { codigo: 2 }, select: { id: true } }),
      this.prisma.tipo_documento_identidad.findFirst({ where: { codigo: 5 }, select: { id: true } }),
      this.prisma.paises.findFirst({ where: { codigo: 'PRY' }, select: { id: true } }),
      this.prisma.tipo_operacion.findFirst({ where: { codigo: 2 }, select: { id: true } }), // B2C
    ]);
    if (!tipoB2C) throw new BadRequestException('No existe el tipo de operación B2C.');
    const datosFiscales = {
      naturaleza_id: naturaleza?.id ?? null,
      tipo_documento_id: innominado?.id ?? null,
      nro_documento: '0',
      pais_id: paraguay?.id ?? null,
    };

    const existing = await this.prisma.clientes.findFirst({
      where: { deleted: false, personas: { empresa_id: empresaId, razon_social: CLIENTE_GENERICO } },
      select: { id: true, persona_id: true, personas: { select: { naturaleza_id: true } } },
    });
    if (existing) {
      if (!existing.personas?.naturaleza_id) {
        await this.prisma.personas.update({ where: { id: existing.persona_id }, data: datosFiscales });
        this.logger.log(`Cliente genérico ecommerce de ${empresaId}: datos fiscales completados.`);
      }
      return existing.id;
    }

    const persona = await this.prisma.personas.create({
      data: { razon_social: CLIENTE_GENERICO, empresa_id: empresaId, ...datosFiscales },
      select: { id: true },
    });
    const cliente = await this.prisma.clientes.create({
      data: {
        persona_id: persona.id,
        tipo_operacion_id: tipoB2C.id,
        nombre_fantasia: CLIENTE_GENERICO,
        tipo_cliente: 'minorista',
      },
      select: { id: true },
    });
    this.logger.log(`Cliente genérico ecommerce creado para empresa ${empresaId}: ${cliente.id}`);
    return cliente.id;
  }

  /**
   * Factura del pedido ERP (la última no anulada). Entregar o retirar un pedido no
   * lo factura, y el panel no lo decía: el pedido quedaba «Entregado» con el pedido
   * ERP en «confirmado» y nadie se enteraba de que faltaba facturar.
   */
  private async attachFacturas<T extends { pedido_id?: string | null }>(empresaId: string, items: T[]) {
    const ids = [...new Set(items.map((i) => i.pedido_id).filter((x): x is string => Boolean(x)))];
    if (!ids.length) return items.map((i) => ({ ...i, factura: null }));
    const filas = await this.prisma.$queryRaw<{ pedido_id: string; numero: string; estado_sifen: string | null }[]>`
      SELECT DISTINCT ON (pedido_id) pedido_id,
             dest || '-' || dpunexp || '-' || dnumdoc AS numero,
             estado_sifen
      FROM factura_cab
      WHERE empresa_id = ${empresaId}::uuid
        AND pedido_id = ANY(${ids}::uuid[])
        AND COALESCE(estado, '') NOT ILIKE 'anulad%'
      ORDER BY pedido_id, created_at DESC`;
    const porPedido = new Map(filas.map((f) => [f.pedido_id, { numero: f.numero, estado_sifen: f.estado_sifen }]));
    return items.map((i) => ({ ...i, factura: (i.pedido_id && porPedido.get(i.pedido_id)) || null }));
  }

  private serialize(s: any, full = false, fallbackWorkflow?: unknown) {
    const deliveryType = resolveDeliveryType(s.delivery_snapshot);
    const workflow = normalizeWorkflowConfig(s.workflow_snapshot ?? fallbackWorkflow);
    const estado = effectiveWorkflowState(s.estado, deliveryType);
    const base = {
      id: s.id,
      codigo: s.codigo,
      estado,
      metodo_pago: s.metodo_pago,
      customer: s.customer_snapshot,
      totals: s.totals_snapshot,
      created_at: s.created_at,
      proximos_estados: nextWorkflowStates(s.estado, deliveryType, workflow),
      delivery_type: deliveryType,
      pedido_id: s.pedido_id,
    };
    if (!full) return base;
    return {
      ...base,
      delivery: s.delivery_snapshot,
      items: s.items_snapshot,
      notas: s.notas,
      // Indicaciones del comprador. Separadas de `notas` (operador/sistema) para
      // que quede claro quién escribió qué.
      comentarios_cliente: s.comentarios_cliente ?? null,
      confirmado_at: s.confirmado_at,
      workflow,
      operador_asignado_id: s.operador_asignado_id,
      asignado_at: s.asignado_at,
    };
  }
}
