import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
import { LeadsService } from './leads.service';

describe('LeadsService', () => {
  const crear = (overrides: Record<string, any> = {}) => {
    const prisma: any = {
      clientes: { findFirst: jest.fn().mockResolvedValue(null) },
      crm_leads: {
        findFirst: jest.fn().mockResolvedValue(null),
        findMany: jest.fn().mockResolvedValue([]),
        groupBy: jest.fn().mockResolvedValue([]),
        create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'lead-1', ...data })),
        update: jest.fn().mockImplementation(({ where, data }) => Promise.resolve({ id: where.id, ...data })),
      },
      crm_motivos: { findFirst: jest.fn().mockResolvedValue({ id: 'mot-1' }) },
      naturaleza_receptor: { findFirst: jest.fn().mockResolvedValue({ id: 'nat-contrib' }) },
      tipo_operacion: {
        findUnique: jest.fn().mockResolvedValue({ codigo: 2, descripcion: 'B2C' }),
        findFirst: jest.fn().mockResolvedValue({ id: 'tipo-derivado' }),
      },
      vendedores_cobradores: { findMany: jest.fn().mockResolvedValue([]) },
      ...overrides,
    };
    const audit = { log: jest.fn().mockResolvedValue(undefined) };
    const clientes = {
      create: jest.fn().mockResolvedValue({ data: { cliente: { id: 'cli-nuevo' }, persona: { id: 'per-1' } } }),
    };
    return { service: new LeadsService(prisma, audit as any, clientes as any), prisma, audit, clientes };
  };

  /**
   * Estos casos salieron de cargar datos de demostración en test: la conversión
   * devolvía 500. `CreateClienteDto` extiende `CreatePersonaDto`, que exige
   * `naturaleza_id`, y el `as any` con el que estaba escrita la llamada tapaba
   * que faltara. El cast ahora es a `CreateClienteDto`, así que TS lo verifica.
   */
  describe('convertir — lo que exige crear el cliente', () => {
    /** Lo mínimo que exige crear un cliente no contribuyente. */
    const FISCAL = (over: Record<string, any> = {}) => ({
      tipo_documento_id: 'doc-ci', nro_documento: '9999001', ...over,
    });

    const LEAD = (over: Record<string, any> = {}) => ({
      id: 'lead-1', empresa_id: 'emp-1', nombre: 'Lourdes Cabral',
      empresa_nombre: 'Peluquería Lourdes', ruc: null, dv: null,
      email: 'l@x.test', telefono_e164: '+595981000001', estado: 'nuevo', ...over,
    });

    /**
     * `PersonasService` exige los datos fiscales uno por uno con un 409 desde el
     * fondo de la llamada. Validarlos antes permite decir todo lo que falta de
     * una sola vez.
     */
    it('sin RUC exige tipo y número de documento, y los nombra juntos', async () => {
      const { service } = crear({
        crm_leads: { findFirst: jest.fn().mockResolvedValue(LEAD()), update: jest.fn().mockResolvedValue({}) },
      });
      await expect(service.convertir('lead-1', {}, 'emp-1')).rejects.toMatchObject({
        response: { campos_faltantes: ['tipo_documento_id', 'nro_documento'] },
      });
    });

    it('con RUC exige el tipo de contribuyente', async () => {
      const { service } = crear({
        crm_leads: {
          findFirst: jest.fn().mockResolvedValue(LEAD({ ruc: '80012345', dv: '6' })),
          update: jest.fn().mockResolvedValue({}),
        },
      });
      await expect(service.convertir('lead-1', {}, 'emp-1')).rejects.toMatchObject({
        response: { campos_faltantes: ['tipo_contribuyente_id'] },
      });
    });

    it('el mensaje explica por qué hace falta cada dato', async () => {
      const { service } = crear({
        crm_leads: { findFirst: jest.fn().mockResolvedValue(LEAD()), update: jest.fn().mockResolvedValue({}) },
      });
      await expect(service.convertir('lead-1', {}, 'emp-1')).rejects.toThrow(
        /no tiene RUC.*no es contribuyente/,
      );
    });

    it('con los datos fiscales completos los pasa al cliente', async () => {
      const { service, clientes } = crear({
        crm_leads: { findFirst: jest.fn().mockResolvedValue(LEAD()), update: jest.fn().mockResolvedValue({}) },
      });
      await service.convertir(
        'lead-1',
        { tipo_documento_id: 'doc-ci', nro_documento: '9999001' },
        'emp-1',
      );
      expect(clientes.create.mock.calls[0][0]).toMatchObject({
        tipo_documento_id: 'doc-ci', nro_documento: '9999001',
      });
    });

    /**
     * Antes se podía convertir un lead con cualquier tipo de operación y el
     * problema aparecía recién al cerrar la venta, con el cliente ya creado:
     * SIFEN no acepta B2B para un no contribuyente.
     */
    describe('tipo de operación', () => {
      it('sin tipo lo deriva de la naturaleza', async () => {
        const { service, clientes, prisma } = crear({
          crm_leads: { findFirst: jest.fn().mockResolvedValue(LEAD()), update: jest.fn().mockResolvedValue({}) },
        });
        await service.convertir('lead-1', { tipo_documento_id: 'doc', nro_documento: '9999001' }, 'emp-1');
        expect(prisma.tipo_operacion.findFirst.mock.calls[0][0].where.codigo).toBe(2); // B2C
        expect(clientes.create.mock.calls[0][0].tipo_operacion_id).toBe('tipo-derivado');
      });

      it('para un contribuyente deriva B2B', async () => {
        const { service, prisma } = crear({
          crm_leads: {
            findFirst: jest.fn().mockResolvedValue(LEAD({ ruc: '80012345', dv: '6' })),
            update: jest.fn().mockResolvedValue({}),
          },
        });
        await service.convertir('lead-1', { tipo_contribuyente_id: 'tc-1' }, 'emp-1');
        expect(prisma.tipo_operacion.findFirst.mock.calls[0][0].where.codigo).toBe(1); // B2B
      });

      it('rechaza B2B para un no contribuyente, antes de crear el cliente', async () => {
        const { service, clientes } = crear({
          crm_leads: { findFirst: jest.fn().mockResolvedValue(LEAD()), update: jest.fn().mockResolvedValue({}) },
          tipo_operacion: { findUnique: jest.fn().mockResolvedValue({ codigo: 1, descripcion: 'B2B' }) },
        });
        await expect(
          service.convertir('lead-1', FISCAL({ tipo_operacion_id: 'tipo-b2b' }), 'emp-1'),
        ).rejects.toThrow(/B2C o B2F/);
        expect(clientes.create).not.toHaveBeenCalled();
      });

      it('rechaza B2C para un contribuyente', async () => {
        const { service } = crear({
          crm_leads: {
            findFirst: jest.fn().mockResolvedValue(LEAD({ ruc: '80012345', dv: '6' })),
            update: jest.fn().mockResolvedValue({}),
          },
          tipo_operacion: { findUnique: jest.fn().mockResolvedValue({ codigo: 2, descripcion: 'B2C' }) },
        });
        await expect(
          service.convertir('lead-1', { tipo_operacion_id: 'tipo-b2c', tipo_contribuyente_id: 'tc-1' }, 'emp-1'),
        ).rejects.toThrow(/B2B o B2G/);
      });

      it('acepta B2F para un no contribuyente', async () => {
        const { service, clientes } = crear({
          crm_leads: { findFirst: jest.fn().mockResolvedValue(LEAD()), update: jest.fn().mockResolvedValue({}) },
          tipo_operacion: { findUnique: jest.fn().mockResolvedValue({ codigo: 4, descripcion: 'B2F' }) },
        });
        await service.convertir('lead-1', FISCAL({ tipo_operacion_id: 'tipo-b2f' }), 'emp-1');
        expect(clientes.create.mock.calls[0][0].tipo_operacion_id).toBe('tipo-b2f');
      });

      it('un tipo inexistente se rechaza', async () => {
        const { service } = crear({
          crm_leads: { findFirst: jest.fn().mockResolvedValue(LEAD()), update: jest.fn().mockResolvedValue({}) },
          tipo_operacion: { findUnique: jest.fn().mockResolvedValue(null) },
        });
        await expect(
          service.convertir('lead-1', FISCAL({ tipo_operacion_id: 'inventado' }), 'emp-1'),
        ).rejects.toThrow(/no existe/);
      });
    });

    it('manda naturaleza_id al crear el cliente', async () => {
      const { service, clientes } = crear({
        crm_leads: {
          findFirst: jest.fn().mockResolvedValue(LEAD()),
          update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'lead-1', ...data })),
        },
      });

      await service.convertir('lead-1', FISCAL(), 'emp-1');

      expect(clientes.create.mock.calls[0][0]).toMatchObject({
        tipo_operacion_id: 'tipo-derivado',
        naturaleza_id: 'nat-contrib',
        razon_social: 'Peluquería Lourdes',
      });
    });

    /** Un contribuyente es, por definición, el que tiene RUC. */
    it('sin RUC busca "no contribuyente"; con RUC busca "Contribuyente"', async () => {
      const { service, prisma } = crear({
        crm_leads: {
          findFirst: jest.fn().mockResolvedValue(LEAD()),
          update: jest.fn().mockResolvedValue({}),
        },
      });
      await service.convertir('lead-1', FISCAL(), 'emp-1');
      expect(prisma.naturaleza_receptor.findFirst.mock.calls[0][0].where.descripcion.equals).toBe('no contribuyente');

      const conRuc = crear({
        crm_leads: {
          findFirst: jest.fn().mockResolvedValue(LEAD({ ruc: '80012345', dv: '6' })),
          update: jest.fn().mockResolvedValue({}),
        },
      });
      await conRuc.service.convertir('lead-1', { tipo_contribuyente_id: 'tc-1' }, 'emp-1');
      expect(conRuc.prisma.naturaleza_receptor.findFirst.mock.calls[0][0].where.descripcion.equals).toBe('Contribuyente');
    });

    /** En el lead el DV es texto; en la persona es número. */
    it('convierte el DV a número', async () => {
      const { service, clientes } = crear({
        crm_leads: {
          findFirst: jest.fn().mockResolvedValue(LEAD({ ruc: '80012345', dv: '6' })),
          update: jest.fn().mockResolvedValue({}),
        },
      });
      await service.convertir('lead-1', { tipo_contribuyente_id: 'tc-1' }, 'emp-1');
      expect(clientes.create.mock.calls[0][0].dv).toBe(6);
    });

    it('sin DV no manda el campo', async () => {
      const { service, clientes } = crear({
        crm_leads: { findFirst: jest.fn().mockResolvedValue(LEAD()), update: jest.fn().mockResolvedValue({}) },
      });
      await service.convertir('lead-1', FISCAL(), 'emp-1');
      expect(clientes.create.mock.calls[0][0].dv).toBeUndefined();
    });

    it('un naturaleza_id explícito le gana a la deducción', async () => {
      const { service, clientes, prisma } = crear({
        crm_leads: { findFirst: jest.fn().mockResolvedValue(LEAD()), update: jest.fn().mockResolvedValue({}) },
      });
      await service.convertir('lead-1', FISCAL({ naturaleza_id: 'nat-elegida' }), 'emp-1');
      expect(clientes.create.mock.calls[0][0].naturaleza_id).toBe('nat-elegida');
      expect(prisma.naturaleza_receptor.findFirst).not.toHaveBeenCalled();
    });

    it('si el catálogo no tiene la naturaleza, el error dice qué mandar', async () => {
      const { service } = crear({
        crm_leads: { findFirst: jest.fn().mockResolvedValue(LEAD()), update: jest.fn().mockResolvedValue({}) },
        naturaleza_receptor: { findFirst: jest.fn().mockResolvedValue(null) },
      });
      await expect(service.convertir('lead-1', FISCAL(), 'emp-1')).rejects.toThrow(
        /naturaleza_id explícito/,
      );
    });

    it('usa el nombre del lead cuando no hay empresa ni razón social', async () => {
      const { service, clientes } = crear({
        crm_leads: {
          findFirst: jest.fn().mockResolvedValue(LEAD({ empresa_nombre: null })),
          update: jest.fn().mockResolvedValue({}),
        },
      });
      await service.convertir('lead-1', FISCAL(), 'emp-1');
      expect(clientes.create.mock.calls[0][0].razon_social).toBe('Lourdes Cabral');
    });
  });

  describe('deduplicación', () => {
    it('si el contacto YA es cliente no crea lead y devuelve a quién encontró', async () => {
      const { service, prisma } = crear({
        clientes: {
          findFirst: jest.fn().mockResolvedValue({ id: 'cli-9', personas: { razon_social: 'Ferretería Central' } }),
        },
      });

      const r: any = await service.create({ nombre: 'Juan', ruc: '80012345' } as any, 'emp-1');

      expect(r).toEqual({ duplicado: 'cliente', cliente_id: 'cli-9', razon_social: 'Ferretería Central' });
      expect(prisma.crm_leads.create).not.toHaveBeenCalled();
    });

    it('si ya hay un lead abierto devuelve ese, sin duplicar', async () => {
      const { service, prisma } = crear({
        crm_leads: {
          findFirst: jest.fn().mockResolvedValue({ id: 'lead-viejo', nombre: 'Juan' }),
          create: jest.fn(),
          groupBy: jest.fn().mockResolvedValue([]),
        },
      });

      const r: any = await service.create({ nombre: 'Juan', telefono: '0981123456' } as any, 'emp-1');

      expect(r.duplicado).toBe('lead');
      expect(prisma.crm_leads.create).not.toHaveBeenCalled();
    });

    it('el lead descartado no bloquea: se busca sólo entre los abiertos', async () => {
      const { service, prisma } = crear();

      await service.create({ nombre: 'Juan', telefono: '0981123456' } as any, 'emp-1');

      expect(prisma.crm_leads.findFirst.mock.calls[0][0].where.estado).toEqual({ not: 'descartado' });
    });

    it('busca el cliente por los últimos 8 dígitos: el padrón guarda el teléfono sin normalizar', async () => {
      const { service, prisma } = crear();

      await service.create({ nombre: 'Juan', telefono: '+595981123456' } as any, 'emp-1');

      const or = prisma.clientes.findFirst.mock.calls[0][0].where.personas.OR;
      expect(or).toEqual(
        expect.arrayContaining([{ celular: { endsWith: '81123456' } }, { telefono: { endsWith: '81123456' } }]),
      );
    });
  });

  describe('normalización', () => {
    it('guarda el teléfono en E.164 venga como venga', async () => {
      const { service, prisma } = crear();

      await service.create({ nombre: 'Juan', telefono: '0981 123-456', email: '  JUAN@X.COM ' } as any, 'emp-1');

      const data = prisma.crm_leads.create.mock.calls[0][0].data;
      expect(data.telefono_e164).toBe('+595981123456');
      expect(data.email).toBe('juan@x.com');
    });

    it('un teléfono que no se puede interpretar es un error, no un null silencioso', async () => {
      const { service } = crear();

      await expect(service.create({ nombre: 'Juan', telefono: 'no tengo' } as any, 'emp-1')).rejects.toThrow(
        BadRequestException,
      );
    });
  });

  describe('asignación automática', () => {
    it('le toca al vendedor con menos leads abiertos', async () => {
      const { service, prisma } = crear({
        vendedores_cobradores: {
          findMany: jest.fn().mockResolvedValue([{ id: 'v-ana' }, { id: 'v-beto' }]),
        },
        crm_leads: {
          findFirst: jest.fn().mockResolvedValue(null),
          create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'l', ...data })),
          groupBy: jest.fn().mockResolvedValue([
            { asignado_a: 'v-ana', _count: { _all: 7 } },
            { asignado_a: 'v-beto', _count: { _all: 2 } },
          ]),
        },
      });

      await service.create({ nombre: 'Juan' } as any, 'emp-1');

      expect(prisma.crm_leads.create.mock.calls[0][0].data.asignado_a).toBe('v-beto');
    });

    it('sin vendedores cargados el lead queda sin asignar, no rompe', async () => {
      const { service, prisma } = crear();

      await service.create({ nombre: 'Juan' } as any, 'emp-1');

      expect(prisma.crm_leads.create.mock.calls[0][0].data.asignado_a).toBeNull();
    });

    it('respeta el vendedor que venga explícito', async () => {
      const { service, prisma } = crear({
        vendedores_cobradores: { findMany: jest.fn().mockResolvedValue([{ id: 'v-ana' }]) },
      });

      await service.create({ nombre: 'Juan', asignado_a: 'v-elegido' } as any, 'emp-1');

      expect(prisma.crm_leads.create.mock.calls[0][0].data.asignado_a).toBe('v-elegido');
      expect(prisma.vendedores_cobradores.findMany).not.toHaveBeenCalled();
    });
  });

  describe('conversión', () => {
    const abierto = { id: 'lead-1', empresa_id: 'emp-1', estado: 'calificado', nombre: 'Juan', ruc: '80012345', dv: '6', email: 'j@x.com', telefono_e164: '+595981123456', empresa_nombre: null };

    it('crea el cliente vía ClientesService, nunca por insert directo', async () => {
      const { service, clientes, prisma } = crear({
        crm_leads: {
          findFirst: jest.fn().mockResolvedValue(abierto),
          update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'lead-1', ...data })),
        },
      });

      // El lead tiene RUC, así que el cliente es contribuyente y necesita su tipo.
      // Este test pasaba sin eso porque el mock de ClientesService no lo exigía,
      // pero en producción PersonasService devolvía 409.
      await service.convertir(
        'lead-1',
        { tipo_contribuyente_id: 'tc-1' } as any,
        'emp-1',
        'user-1',
      );

      expect(clientes.create).toHaveBeenCalledTimes(1);
      const dto = clientes.create.mock.calls[0][0];
      expect(dto.ruc).toBe('80012345');
      expect(dto.celular).toBe('+595981123456');
      expect(prisma.crm_leads.update.mock.calls[0][0].data.estado).toBe('convertido');
    });

    it('sin tipo_operacion_id no se puede crear el cliente', async () => {
      const { service } = crear({ crm_leads: { findFirst: jest.fn().mockResolvedValue(abierto) } });

      await expect(service.convertir('lead-1', {} as any, 'emp-1')).rejects.toThrow(BadRequestException);
    });

    it('no se convierte dos veces', async () => {
      const { service } = crear({
        crm_leads: { findFirst: jest.fn().mockResolvedValue({ ...abierto, estado: 'convertido' }) },
      });

      await expect(service.convertir('lead-1', { tipo_operacion_id: 't' } as any, 'emp-1')).rejects.toThrow(
        ConflictException,
      );
    });

    it('vincular un cliente de otra empresa falla', async () => {
      const { service } = crear({
        crm_leads: { findFirst: jest.fn().mockResolvedValue(abierto) },
        clientes: { findFirst: jest.fn().mockResolvedValue(null) },
      });

      await expect(
        service.convertir('lead-1', { cliente_id: 'cli-ajeno' } as any, 'emp-1'),
      ).rejects.toThrow(NotFoundException);
    });
  });

  describe('descarte', () => {
    const abierto = { id: 'lead-1', empresa_id: 'emp-1', estado: 'nuevo' };

    it('exige un motivo de tipo descarte', async () => {
      const { service } = crear({
        crm_leads: { findFirst: jest.fn().mockResolvedValue(abierto) },
        crm_motivos: { findFirst: jest.fn().mockResolvedValue(null) },
      });

      await expect(
        service.descartar('lead-1', { motivo_descarte_id: 'mot-de-perdida' } as any, 'emp-1'),
      ).rejects.toThrow(BadRequestException);
    });

    it('un lead convertido no se descarta', async () => {
      const { service } = crear({
        crm_leads: { findFirst: jest.fn().mockResolvedValue({ ...abierto, estado: 'convertido' }) },
      });

      await expect(service.descartar('lead-1', { motivo_descarte_id: 'm' } as any, 'emp-1')).rejects.toThrow(
        ConflictException,
      );
    });
  });

  describe('multiempresa', () => {
    it('toda lectura filtra por empresa_id', async () => {
      const { service, prisma } = crear();

      await service.findAll('emp-1', { estado: 'nuevo' });

      expect(prisma.crm_leads.findMany.mock.calls[0][0].where).toMatchObject({ empresa_id: 'emp-1' });
    });
  });
});
