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

const ETAPA_INICIAL = { id: 'eta-1', nombre: 'Calificación', orden: 0, probabilidad: 10, campos_obligatorios: [], es_ganada: false, es_perdida: false, activo: true };
const PIPELINE = { id: 'pip-1', empresa_id: 'emp-1', nombre: 'Ventas', activo: true };
const TODAS = { verAjenas: true };

describe('OportunidadesService', () => {
  const crear = (overrides: Record<string, any> = {}) => {
    const cliente: any = {
      crm_pipelines: { findFirst: jest.fn().mockResolvedValue(PIPELINE) },
      crm_etapas: { findFirst: jest.fn().mockResolvedValue(ETAPA_INICIAL) },
      crm_motivos: { findFirst: jest.fn().mockResolvedValue({ id: 'mot-1' }) },
      crm_oportunidades: {
        findFirst: jest.fn().mockResolvedValue(null),
        findMany: jest.fn().mockResolvedValue([]),
        create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'opo-1', ...data })),
        update: jest.fn().mockImplementation(({ where, data }) => Promise.resolve({ id: where.id, ...data })),
      },
      crm_oportunidad_items: {
        createMany: jest.fn().mockResolvedValue({ count: 0 }),
        deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
      },
      productos: { findFirst: jest.fn().mockResolvedValue({ id: 'prod-1', descripcion: 'Taladro X200', porcentaje_iva: 10 }) },
      vendedores_cobradores: { findFirst: jest.fn().mockResolvedValue({ id: 'v-1' }) },
      usuario: { findUnique: jest.fn().mockResolvedValue({ is_superadmin: false }) },
      empresas: { findUnique: jest.fn().mockResolvedValue({ type: 'subsidiary' }) },
      ...overrides,
    };
    const prisma: any = { ...cliente, $transaction: jest.fn().mockImplementation((cb: any) => cb(cliente)) };
    const audit = { log: jest.fn().mockResolvedValue(undefined) };
    const numeracion = { siguiente: jest.fn().mockResolvedValue('OPP-0000001') };
    const precios = { obtenerPrecioProducto: jest.fn().mockResolvedValue({ precioFinal: 250_000 }) };
    return {
      service: new OportunidadesService(prisma, audit as any, numeracion as any, precios as any),
      prisma, cliente, audit, numeracion, precios,
    };
  };

  describe('create', () => {
    it('exige cliente o lead', async () => {
      const { service } = crear();
      await expect(service.create({ titulo: 'X' } as any, 'emp-1')).rejects.toThrow(BadRequestException);
    });

    /**
     * La regla más importante del ticket: con producto, el precio sale de la
     * lista del cliente. Aceptarlo del body dejaría vender por debajo del mínimo
     * sin ningún control.
     */
    it('ignora el precio del body cuando el ítem tiene producto', async () => {
      const { service, cliente, precios } = crear();

      await service.create(
        { titulo: 'X', cliente_id: 'cli-1', items: [{ producto_id: 'prod-1', cantidad: 2, precio_unitario: 1 }] } as any,
        'emp-1',
      );

      expect(precios.obtenerPrecioProducto).toHaveBeenCalledWith(
        expect.objectContaining({ productoId: 'prod-1', clienteId: 'cli-1', empresaId: 'emp-1' }),
      );
      const item = cliente.crm_oportunidad_items.createMany.mock.calls[0][0].data[0];
      expect(Number(item.precio_unitario)).toBe(250_000);
      expect(Number(item.subtotal)).toBe(500_000);
    });

    it('respeta el precio del body en un ítem sin producto', async () => {
      const { service, cliente, precios } = crear();

      await service.create(
        { titulo: 'X', cliente_id: 'cli-1', items: [{ descripcion: 'Flete', cantidad: 1, precio_unitario: 80_000 }] } as any,
        'emp-1',
      );

      expect(precios.obtenerPrecioProducto).not.toHaveBeenCalled();
      expect(Number(cliente.crm_oportunidad_items.createMany.mock.calls[0][0].data[0].precio_unitario)).toBe(80_000);
    });

    it('un ítem sin producto y sin descripción no se puede guardar', async () => {
      const { service } = crear();
      await expect(
        service.create({ titulo: 'X', cliente_id: 'c', items: [{ cantidad: 1, precio_unitario: 10 }] } as any, 'emp-1'),
      ).rejects.toThrow(BadRequestException);
    });

    it('un ítem recurrente sin frecuencia no se puede facturar por ciclo', async () => {
      const { service } = crear();
      await expect(
        service.create(
          { titulo: 'X', cliente_id: 'c', items: [{ descripcion: 'Abono', cantidad: 1, precio_unitario: 1, tipo_cobro: 'recurrente' }] } as any,
          'emp-1',
        ),
      ).rejects.toThrow(BadRequestException);
    });

    it('pide el número DENTRO de la transacción: un alta fallida no deja huecos', async () => {
      const { service, numeracion, cliente } = crear();

      await service.create({ titulo: 'X', cliente_id: 'cli-1' } as any, 'emp-1');

      expect(numeracion.siguiente).toHaveBeenCalledWith('emp-1', 'OPP', cliente);
      expect(cliente.crm_oportunidades.create.mock.calls[0][0].data.numero).toBe('OPP-0000001');
    });

    it('hereda la probabilidad de la etapa inicial', async () => {
      const { service, cliente } = crear();
      await service.create({ titulo: 'X', cliente_id: 'cli-1' } as any, 'emp-1');
      expect(cliente.crm_oportunidades.create.mock.calls[0][0].data.probabilidad).toBe(10);
    });

    it('no deja nacer una oportunidad en la etapa ganada', async () => {
      const { service } = crear({
        crm_etapas: { findFirst: jest.fn().mockResolvedValue({ ...ETAPA_INICIAL, es_ganada: true }) },
      });
      await expect(
        service.create({ titulo: 'X', cliente_id: 'c', etapa_id: 'eta-ganada' } as any, 'emp-1'),
      ).rejects.toThrow(ConflictException);
    });
  });

  describe('moverEtapa', () => {
    const abierta = { id: 'opo-1', empresa_id: 'emp-1', estado: 'abierta', pipeline_id: 'pip-1', etapa_id: 'eta-1', presupuesto_id: null, valor_unico: 0 };

    /** Ganar emite documentos: no puede pasar por un drag & drop. */
    it('a la etapa ganada devuelve 409 pidiendo el asistente de cierre', async () => {
      const { service } = crear({
        crm_oportunidades: { findFirst: jest.fn().mockResolvedValue(abierta), update: jest.fn() },
        crm_etapas: { findFirst: jest.fn().mockResolvedValue({ ...ETAPA_INICIAL, es_ganada: true, nombre: 'Ganada' }) },
      });

      await expect(service.moverEtapa('opo-1', { etapa_id: 'eta-g' } as any, 'emp-1', TODAS)).rejects.toMatchObject({
        response: { requiere: 'cierre' },
      });
    });

    it('a la etapa perdida manda a usar el endpoint que exige motivo', async () => {
      const { service } = crear({
        crm_oportunidades: { findFirst: jest.fn().mockResolvedValue(abierta), update: jest.fn() },
        crm_etapas: { findFirst: jest.fn().mockResolvedValue({ ...ETAPA_INICIAL, es_perdida: true }) },
      });

      await expect(service.moverEtapa('opo-1', { etapa_id: 'eta-p' } as any, 'emp-1', TODAS)).rejects.toMatchObject({
        response: { requiere: 'motivo_perdida' },
      });
    });

    it('frena si faltan los campos obligatorios del destino, y dice cuáles', async () => {
      const { service } = crear({
        crm_oportunidades: { findFirst: jest.fn().mockResolvedValue(abierta), update: jest.fn() },
        crm_etapas: {
          findFirst: jest.fn().mockResolvedValue({
            ...ETAPA_INICIAL, nombre: 'Propuesta enviada', campos_obligatorios: ['presupuesto_id', 'valor_unico'],
          }),
        },
      });

      await expect(service.moverEtapa('opo-1', { etapa_id: 'eta-3' } as any, 'emp-1', TODAS)).rejects.toMatchObject({
        response: { campos_faltantes: ['presupuesto_id', 'valor_unico'] },
      });
    });

    it('con los obligatorios cargados, mueve y adopta la probabilidad del destino', async () => {
      const { service, cliente } = crear({
        crm_oportunidades: {
          findFirst: jest.fn().mockResolvedValue({ ...abierta, presupuesto_id: 'pre-1' }),
          update: jest.fn().mockImplementation(({ where, data }) => Promise.resolve({ id: where.id, ...data })),
        },
        crm_etapas: {
          findFirst: jest.fn().mockResolvedValue({ ...ETAPA_INICIAL, id: 'eta-3', probabilidad: 50, campos_obligatorios: ['presupuesto_id'] }),
        },
      });

      await service.moverEtapa('opo-1', { etapa_id: 'eta-3' } as any, 'emp-1', TODAS);

      const data = cliente.crm_oportunidades.update.mock.calls[0][0].data;
      expect(data.etapa_id).toBe('eta-3');
      expect(data.probabilidad).toBe(50);
    });
  });

  describe('perder y reabrir', () => {
    const abierta = { id: 'opo-1', empresa_id: 'emp-1', estado: 'abierta', pipeline_id: 'pip-1' };

    it('el motivo tiene que ser de tipo "perdida"', async () => {
      const { service } = crear({
        crm_oportunidades: { findFirst: jest.fn().mockResolvedValue(abierta), update: jest.fn() },
        crm_motivos: { findFirst: jest.fn().mockResolvedValue(null) },
      });

      await expect(
        service.perder('opo-1', { motivo_perdida_id: 'mot-de-descarte' } as any, 'emp-1', TODAS),
      ).rejects.toThrow(BadRequestException);
    });

    it('una oportunidad ganada no se reabre: ya generó documentos', async () => {
      const { service } = crear({
        crm_oportunidades: { findFirst: jest.fn().mockResolvedValue({ ...abierta, estado: 'ganada' }), update: jest.fn() },
      });

      await expect(service.reabrir('opo-1', 'emp-1', TODAS)).rejects.toThrow(ConflictException);
    });
  });

  describe('visibilidad', () => {
    it('sin VER_AJENAS sólo ve las del vendedor', async () => {
      const { service, prisma } = crear();

      await service.findAll('emp-1', { vendedor_id: 'v-1', verAjenas: false });

      expect(prisma.crm_oportunidades.findMany.mock.calls[0][0].where).toMatchObject({ vendedor_id: 'v-1' });
    });

    it('un usuario que no es vendedor y no tiene VER_AJENAS no ve nada', async () => {
      const { service, prisma } = crear();

      await service.findAll('emp-1', { vendedor_id: null, verAjenas: false });

      // Un UUID imposible en vez de "sin filtro": mejor lista vacía que mostrar
      // el pipeline entero a quien no corresponde.
      expect(prisma.crm_oportunidades.findMany.mock.calls[0][0].where.vendedor_id).toBe(
        '00000000-0000-0000-0000-000000000000',
      );
    });

    it('con VER_AJENAS no filtra por vendedor', async () => {
      const { service, prisma } = crear();

      await service.findAll('emp-1', TODAS);

      expect(prisma.crm_oportunidades.findMany.mock.calls[0][0].where.vendedor_id).toBeUndefined();
    });

    it('findOne de otra empresa o de otro vendedor no aparece', async () => {
      const { service } = crear({ crm_oportunidades: { findFirst: jest.fn().mockResolvedValue(null) } });
      await expect(service.findOne('opo-ajena', 'emp-1', TODAS)).rejects.toThrow(NotFoundException);
    });
  });

  /**
   * Salió de cargar datos de prueba en test: el superadmin creaba oportunidades y
   * después el detalle le devolvía 404. Sin perfil no tiene VER_AJENAS y, al no
   * estar dado de alta como vendedor, el filtro por propiedad lo dejaba sin nada.
   */
  describe('resolverAlcance', () => {
    it('con el privilegio ve todo', async () => {
      const { service } = crear();
      expect(await service.resolverAlcance('emp-1', 'u-1', true)).toEqual({ verAjenas: true });
    });

    it('un superadmin ve todo aunque no sea vendedor', async () => {
      const { service, prisma } = crear();
      prisma.usuario.findUnique.mockResolvedValue({ is_superadmin: true });
      expect(await service.resolverAlcance('emp-1', 'u-1', false)).toEqual({ verAjenas: true });
      expect(prisma.vendedores_cobradores.findFirst).not.toHaveBeenCalled();
    });

    it('una empresa holding o reseller ve todo', async () => {
      for (const type of ['holding', 'reseller']) {
        const { service, prisma } = crear();
        prisma.empresas.findUnique.mockResolvedValue({ type });
        expect(await service.resolverAlcance('emp-1', 'u-1', false)).toEqual({ verAjenas: true });
      }
    });

    it('un vendedor normal queda acotado a lo suyo', async () => {
      const { service } = crear();
      expect(await service.resolverAlcance('emp-1', 'u-1', false)).toEqual({
        vendedor_id: 'v-1', verAjenas: false,
      });
    });

    /** Mejor una lista vacía que mostrarle el pipeline entero a quien no corresponde. */
    it('un usuario sin perfil ni ficha de vendedor no ve nada', async () => {
      const { service } = crear({ vendedores_cobradores: { findFirst: jest.fn().mockResolvedValue(null) } });
      expect(await service.resolverAlcance('emp-1', 'u-1', false)).toEqual({
        vendedor_id: null, verAjenas: false,
      });
    });
  });
});
