import {
  BadRequestException, ConflictException, ForbiddenException, NotFoundException,
  UnprocessableEntityException,
} from '@nestjs/common';
import { CierreVentaService } from './cierre-venta.service';

const ITEM = (over: Record<string, any> = {}) => ({
  producto_id: 'prod-1', descripcion: 'Taladro X200', cantidad: 10,
  precio_unitario: 110_000, descuento_pct: 0, iva_tasa: 10, subtotal: 1_100_000,
  tipo_cobro: 'unico', orden: 0, ...over,
});

const OPO = (over: Record<string, any> = {}) => ({
  id: 'opo-1', empresa_id: 'emp-1', numero: 'OPP-0000124', estado: 'abierta',
  cliente_id: 'cli-1', sucursal_id: 'suc-1', moneda_id: null, vendedor_id: 'v-1',
  pipeline_id: 'pip-1', presupuesto_id: null, items: [ITEM()], ...over,
});

const CLIENTE = {
  id: 'cli-1',
  persona_id: 'per-1',
  tipo_operacion: { codigo: 1 },
  personas: { razon_social: 'DOBA S.A.', ruc: '80144315', dv: '6', nro_documento: null, email: 'd@x.com', direccion: 'Avd victoria' },
};

describe('CierreVentaService', () => {
  const crear = (over: Record<string, any> = {}) => {
    const prisma: any = {
      crm_oportunidades: {
        findFirst: jest.fn().mockResolvedValue(OPO()),
        findUniqueOrThrow: jest.fn().mockResolvedValue({ pipeline_id: 'pip-1' }),
        update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'opo-1', ...data })),
      },
      clientes: { findFirst: jest.fn().mockResolvedValue(CLIENTE) },
      productos: { findMany: jest.fn().mockResolvedValue([{ id: 'prod-1', descripcion: 'Taladro X200', maneja_inventario: true }]) },
      crm_etapas: { findFirst: jest.fn().mockResolvedValue({ id: 'eta-ganada', probabilidad: 100 }) },
      pedidos: { update: jest.fn().mockResolvedValue({}) },
      factura_cab: { update: jest.fn().mockResolvedValue({}) },
      ...over,
    };
    const clientes = { verificarCredito: jest.fn().mockResolvedValue({ permitido: true, saldo_disponible: 9_000_000 }) };
    const pedidos = {
      crearOrden: jest.fn().mockResolvedValue({ data: { id: 'ov-1' } }),
      confirmarOrden: jest.fn().mockResolvedValue({}),
    };
    const ordenFact = { convertirAFactura: jest.fn().mockResolvedValue({ data: { factura_id: 'fac-1' } }) };
    const override = {
      verificar: jest.fn().mockResolvedValue({ id: 'sup-1', username: 'supervisor' }),
      registrar: jest.fn().mockResolvedValue('aut-1'),
    };
    // Mismo validador que usa FacturasService al emitir: sin errores por defecto.
    const personas = { validatePersonaById: jest.fn().mockResolvedValue([]) };
    return {
      service: new CierreVentaService(
        prisma, clientes as any, pedidos as any, ordenFact as any, override as any, personas as any,
      ),
      prisma, clientes, pedidos, ordenFact, override, personas,
    };
  };

  const DTO = { condicion: 'contado' } as any;

  describe('preview — valida antes de emitir', () => {
    it('una oportunidad ya ganada no se cierra de nuevo', async () => {
      const { service } = crear({ crm_oportunidades: { findFirst: jest.fn().mockResolvedValue(OPO({ estado: 'ganada' })) } });
      await expect(service.preview('opo-1', DTO, 'emp-1')).rejects.toThrow(ConflictException);
    });

    it('sin ítems no hay qué facturar', async () => {
      const { service } = crear({ crm_oportunidades: { findFirst: jest.fn().mockResolvedValue(OPO({ items: [] })) } });
      await expect(service.preview('opo-1', DTO, 'emp-1')).rejects.toThrow(/ítems/i);
    });

    /**
     * Emitir sólo la parte única dejaría lo recurrente sin facturar y nadie se
     * enteraría hasta el mes siguiente. Por eso 422 y no un cierre a medias.
     */
    it('con ítems recurrentes devuelve 422 pidiendo CRM Pro', async () => {
      const { service } = crear({
        crm_oportunidades: {
          findFirst: jest.fn().mockResolvedValue(OPO({ items: [ITEM(), ITEM({ tipo_cobro: 'recurrente', frecuencia: 'mensual' })] })),
        },
      });
      await expect(service.preview('opo-1', DTO, 'emp-1')).rejects.toThrow(UnprocessableEntityException);
    });

    it('una oportunidad de un lead exige indicar el cliente fiscal', async () => {
      const { service } = crear({ crm_oportunidades: { findFirst: jest.fn().mockResolvedValue(OPO({ cliente_id: null })) } });
      await expect(service.preview('opo-1', DTO, 'emp-1')).rejects.toThrow(/cliente fiscal/i);
    });

    /** Un DV mal tipeado lo rechaza SIFEN DESPUÉS de emitir, y ahí hay que anular. */
    it('frena si el DV del RUC no cierra', async () => {
      const { service } = crear({
        clientes: { findFirst: jest.fn().mockResolvedValue({ ...CLIENTE, personas: { ...CLIENTE.personas, dv: '9' } }) },
      });
      await expect(service.preview('opo-1', DTO, 'emp-1')).rejects.toThrow(/DV/i);
    });

    it('sin RUC exige documento del receptor', async () => {
      const { service } = crear({
        clientes: { findFirst: jest.fn().mockResolvedValue({ ...CLIENTE, personas: { ...CLIENTE.personas, ruc: null, dv: null, nro_documento: null } }) },
      });
      await expect(service.preview('opo-1', DTO, 'emp-1')).rejects.toThrow(/documento/i);
    });

    /**
     * Antes el cierre creaba la orden de venta y recién al facturar descubría que
     * el cliente tenía datos que SIFEN no acepta. La orden quedaba creada y cada
     * reintento dejaba otra: tres intentos, tres órdenes reservando stock.
     */
    it('rechaza antes de crear nada si el cliente tiene datos inválidos para SIFEN', async () => {
      const { service, personas, pedidos } = crear();
      personas.validatePersonaById.mockResolvedValue([
        'Tipo de operación inválido para no contribuyente: debe ser B2C o B2F',
      ]);

      await expect(service.ejecutar('opo-1', DTO, 'emp-1', 'u-1')).rejects.toMatchObject({
        response: { errores: ['Tipo de operación inválido para no contribuyente: debe ser B2C o B2F'] },
      });
      expect(pedidos.crearOrden).not.toHaveBeenCalled();
    });

    it('valida con el tipo de operación del cliente, que es lo que SIFEN cruza', async () => {
      const { service, personas } = crear();
      await service.preview('opo-1', DTO, 'emp-1');
      expect(personas.validatePersonaById).toHaveBeenCalledWith('per-1', 'emp-1', {
        tipoOperacionCodigo: 1,
      });
    });

    it('un cliente de otra empresa no existe', async () => {
      const { service } = crear({ clientes: { findFirst: jest.fn().mockResolvedValue(null) } });
      await expect(service.preview('opo-1', DTO, 'emp-1')).rejects.toThrow(NotFoundException);
    });
  });

  describe('preview — payload', () => {
    it('calcula el IVA incluido y los totales', async () => {
      const { service } = crear();
      const r = await service.preview('opo-1', DTO, 'emp-1');
      expect(r.payload.totales.total).toBe(1_100_000);
      expect(r.payload.totales.total_iva).toBe(100_000);
      expect(r.payload.totales.base_gravada_10).toBe(1_000_000);
    });

    it('un contribuyente con RUC va como naturaleza 1 y operación B2B', async () => {
      const { service } = crear();
      const r = await service.preview('opo-1', DTO, 'emp-1');
      expect(r.payload.receptor).toMatchObject({ naturaleza: 1, tipo_operacion: 1, ruc: '80144315' });
    });

    it('un consumidor final sin RUC va como naturaleza 2', async () => {
      const { service } = crear({
        clientes: { findFirst: jest.fn().mockResolvedValue({ ...CLIENTE, personas: { ...CLIENTE.personas, ruc: null, dv: null, nro_documento: '1234567' } }) },
      });
      const r = await service.preview('opo-1', DTO, 'emp-1');
      expect(r.payload.receptor).toMatchObject({ naturaleza: 2, tipo_operacion: 2 });
    });

    it('mercadería y servicio juntos dan transacción mixta (iTipTra 3)', async () => {
      const { service } = crear({
        crm_oportunidades: { findFirst: jest.fn().mockResolvedValue(OPO({ items: [ITEM(), ITEM({ producto_id: 'prod-2', descripcion: 'Instalación' })] })) },
        productos: {
          findMany: jest.fn().mockResolvedValue([
            { id: 'prod-1', descripcion: 'Taladro', maneja_inventario: true },
            { id: 'prod-2', descripcion: 'Instalación', maneja_inventario: false },
          ]),
        },
      });
      const r = await service.preview('opo-1', DTO, 'emp-1');
      expect(r.payload.tipo_transaccion).toBe(3);
    });

    it('la trazabilidad lleva la oportunidad y el vendedor', async () => {
      const { service } = crear();
      const r = await service.preview('opo-1', DTO, 'emp-1');
      expect(r.payload.trazabilidad).toMatchObject({ oportunidad_id: 'opo-1', oportunidad_numero: 'OPP-0000124', vendedor_id: 'v-1' });
    });

    it('sin entrega ni anticipo el destino es factura directa', async () => {
      const { service } = crear();
      expect((await service.preview('opo-1', DTO, 'emp-1')).destino).toBe('factura');
    });

    it('con anticipo o entrega el destino es orden de venta', async () => {
      const { service } = crear();
      expect((await service.preview('opo-1', { ...DTO, anticipo: 500_000 }, 'emp-1')).destino).toBe('orden_venta');
      expect((await service.preview('opo-1', { ...DTO, requiere_entrega: true }, 'emp-1')).destino).toBe('orden_venta');
    });
  });

  describe('crédito', () => {
    it('a contado no se consulta el límite', async () => {
      const { service, clientes } = crear();
      await service.preview('opo-1', DTO, 'emp-1');
      expect(clientes.verificarCredito).not.toHaveBeenCalled();
    });

    it('sin crédito disponible pide override en vez de emitir', async () => {
      const { service, clientes } = crear();
      clientes.verificarCredito.mockResolvedValue({ permitido: false, mensaje: 'Supera el límite', saldo_disponible: 0 });

      await expect(service.preview('opo-1', { ...DTO, condicion: 'credito' }, 'emp-1')).rejects.toMatchObject({
        response: { requiere: 'override_supervisor' },
      });
    });

    it('con PIN de supervisor válido deja pasar y nombra a quién autorizó', async () => {
      const { service, clientes, override } = crear();
      clientes.verificarCredito.mockResolvedValue({ permitido: false, mensaje: 'Supera el límite', saldo_disponible: 0 });

      const dto = { ...DTO, condicion: 'credito', supervisor_id: 'sup-1', pin_supervisor: '1234' };
      const r: any = await service.preview('opo-1', dto, 'emp-1');

      expect(override.verificar).toHaveBeenCalledWith('emp-1', 'sup-1', '1234');
      expect(r.credito).toMatchObject({ override: true, autorizado_por: { id: 'sup-1' } });
    });

    /** El preview no persiste nada, ni siquiera la autorización. */
    it('el preview valida el PIN pero no registra la autorización', async () => {
      const { service, clientes, override } = crear();
      clientes.verificarCredito.mockResolvedValue({ permitido: false, mensaje: 'Supera el límite', saldo_disponible: 0 });

      await service.preview('opo-1', { ...DTO, condicion: 'credito', supervisor_id: 'sup-1', pin_supervisor: '1234' }, 'emp-1');

      expect(override.registrar).not.toHaveBeenCalled();
    });

    it('un PIN incorrecto no deja cerrar', async () => {
      const { service, clientes, override } = crear();
      clientes.verificarCredito.mockResolvedValue({ permitido: false, mensaje: 'Supera el límite', saldo_disponible: 0 });
      override.verificar.mockRejectedValue(new ForbiddenException('PIN incorrecto'));

      await expect(
        service.preview('opo-1', { ...DTO, condicion: 'credito', supervisor_id: 'sup-1', pin_supervisor: '0000' }, 'emp-1'),
      ).rejects.toThrow(ForbiddenException);
    });
  });

  describe('ejecutar', () => {
    it('crea la orden, la confirma, factura y gana la oportunidad', async () => {
      const { service, pedidos, ordenFact, prisma } = crear();

      const r: any = await service.ejecutar('opo-1', DTO, 'emp-1', 'u-1');

      expect(pedidos.crearOrden).toHaveBeenCalled();
      expect(pedidos.confirmarOrden).toHaveBeenCalledWith('ov-1', 'emp-1', 'u-1');
      expect(ordenFact.convertirAFactura).toHaveBeenCalled();
      expect(prisma.crm_oportunidades.update.mock.calls[0][0].data.estado).toBe('ganada');
      expect(r.data).toMatchObject({ orden_id: 'ov-1', factura_id: 'fac-1', destino: 'factura' });
    });

    /** Al revés quedaría una oportunidad ganada sin nada detrás. */
    it('la oportunidad se gana DESPUÉS del documento, no antes', async () => {
      const { service, prisma, ordenFact } = crear();
      const orden: string[] = [];
      ordenFact.convertirAFactura.mockImplementation(() => { orden.push('factura'); return Promise.resolve({ data: { factura_id: 'fac-1' } }); });
      prisma.crm_oportunidades.update.mockImplementation(({ data }: any) => { orden.push('ganada'); return Promise.resolve(data); });

      await service.ejecutar('opo-1', DTO, 'emp-1', 'u-1');

      expect(orden).toEqual(['factura', 'ganada']);
    });

    /** Al revés quedarían overrides aprobados de ventas que nunca existieron. */
    it('registra la autorización del override recién con el documento emitido', async () => {
      const { service, clientes, override, ordenFact } = crear();
      clientes.verificarCredito.mockResolvedValue({ permitido: false, mensaje: 'Supera el límite', saldo_disponible: 0 });
      const orden: string[] = [];
      ordenFact.convertirAFactura.mockImplementation(() => { orden.push('factura'); return Promise.resolve({ data: { factura_id: 'fac-1' } }); });
      override.registrar.mockImplementation(() => { orden.push('autorizacion'); return Promise.resolve('aut-1'); });

      const dto = { ...DTO, condicion: 'credito', supervisor_id: 'sup-1', pin_supervisor: '1234' };
      const r: any = await service.ejecutar('opo-1', dto, 'emp-1', 'u-1');

      expect(orden).toEqual(['factura', 'autorizacion']);
      expect(r.data.autorizacion_id).toBe('aut-1');
      expect(override.registrar.mock.calls[0][3]).toMatchObject({ oportunidad_id: 'opo-1', total: 1_100_000 });
    });

    it('sin override no registra ninguna autorización', async () => {
      const { service, override } = crear();
      await service.ejecutar('opo-1', DTO, 'emp-1', 'u-1');
      expect(override.registrar).not.toHaveBeenCalled();
    });

    it('con anticipo deja la orden sin facturar', async () => {
      const { service, ordenFact } = crear();
      const r: any = await service.ejecutar('opo-1', { ...DTO, anticipo: 500_000 }, 'emp-1', 'u-1');
      expect(ordenFact.convertirAFactura).not.toHaveBeenCalled();
      expect(r.data.factura_id).toBeNull();
    });

    it('un ítem sin producto del catálogo no puede ir a la orden', async () => {
      const { service } = crear({
        crm_oportunidades: { findFirst: jest.fn().mockResolvedValue(OPO({ items: [ITEM({ producto_id: null, descripcion: 'Flete' })] })) },
        productos: { findMany: jest.fn().mockResolvedValue([]) },
      });
      await expect(service.ejecutar('opo-1', DTO, 'emp-1', 'u-1')).rejects.toThrow(BadRequestException);
    });
  });
});
