import { UnauthorizedException } from '@nestjs/common';
import { createHmac } from 'crypto';
import { MetaCloudProvider } from './meta-cloud.provider';
import type { CanalWhatsApp } from './whatsapp-provider.interface';

const SECRETO = 'app-secret-de-prueba';

const CANAL: CanalWhatsApp = {
  id: 'can-1',
  empresa_id: 'emp-1',
  proveedor: 'meta_cloud',
  identificador: '+595981123456',
  identificador_externo: '109876543210',
  credenciales: { access_token: 'EAAG-token', app_secret: SECRETO },
};

const firmar = (cuerpo: string) => ({
  'x-hub-signature-256': `sha256=${createHmac('sha256', SECRETO).update(cuerpo, 'utf8').digest('hex')}`,
});

const MENSAJE = {
  entry: [
    {
      changes: [
        {
          value: {
            metadata: { phone_number_id: '109876543210' },
            contacts: [{ wa_id: '595981999888', profile: { name: 'Ana Giménez' } }],
            messages: [
              {
                from: '595981999888',
                id: 'wamid.ABC',
                timestamp: '1760000000',
                type: 'text',
                text: { body: 'Hola, tienen taladros?' },
              },
            ],
          },
        },
      ],
    },
  ],
};

describe('MetaCloudProvider', () => {
  const provider = new MetaCloudProvider();

  describe('firma del webhook', () => {
    it('acepta un payload con firma válida', () => {
      const crudo = JSON.stringify(MENSAJE);
      expect(provider.parsearWebhook(firmar(crudo), crudo, CANAL)).toHaveLength(1);
    });

    /** Procesar un payload sin firma válida es dejar que cualquiera inyecte mensajes. */
    it('rechaza una firma que no corresponde', () => {
      const crudo = JSON.stringify(MENSAJE);
      expect(() =>
        provider.parsearWebhook({ 'x-hub-signature-256': 'sha256=0000' }, crudo, CANAL),
      ).toThrow(UnauthorizedException);
    });

    it('rechaza si falta la cabecera', () => {
      const crudo = JSON.stringify(MENSAJE);
      expect(() => provider.parsearWebhook({}, crudo, CANAL)).toThrow(/X-Hub-Signature-256/);
    });

    it('rechaza si el canal no tiene app_secret', () => {
      const crudo = JSON.stringify(MENSAJE);
      const sinSecreto = { ...CANAL, credenciales: { access_token: 'x' } };
      expect(() => provider.parsearWebhook(firmar(crudo), crudo, sinSecreto)).toThrow(/app_secret/);
    });

    /**
     * La firma se calcula sobre el cuerpo crudo. Si se recalculara sobre el JSON
     * reparseado, cambiar el orden de las claves rompería firmas válidas.
     */
    it('la firma depende del cuerpo exacto, no del JSON equivalente', () => {
      const crudo = JSON.stringify(MENSAJE);
      const reordenado = JSON.stringify(JSON.parse(crudo), Object.keys(MENSAJE).reverse());
      expect(() => provider.parsearWebhook(firmar(crudo), reordenado, CANAL)).toThrow(UnauthorizedException);
    });

    /** Con firma válida y cuerpo roto, el problema es de Meta: no hay que reventar. */
    it('un cuerpo no parseable con firma válida devuelve lista vacía', () => {
      const crudo = 'esto no es json';
      expect(provider.parsearWebhook(firmar(crudo), crudo, CANAL)).toEqual([]);
    });
  });

  describe('traducción del payload', () => {
    it('extrae el mensaje con su contacto y su hora', () => {
      const crudo = JSON.stringify(MENSAJE);
      const [evento] = provider.parsearWebhook(firmar(crudo), crudo, CANAL);
      expect(evento).toMatchObject({
        clase: 'mensaje',
        identificador_externo: '109876543210',
        telefono: '595981999888',
        nombre_contacto: 'Ana Giménez',
        id_externo: 'wamid.ABC',
        tipo: 'texto',
        texto: 'Hola, tienen taladros?',
      });
      // Meta manda epoch en segundos.
      expect((evento as { fecha: Date }).fecha).toEqual(new Date(1760000000 * 1000));
    });

    it('un adjunto queda con su media id y su mime', () => {
      const payload = {
        entry: [{ changes: [{ value: {
          metadata: { phone_number_id: '109876543210' },
          messages: [{ from: '595981999888', id: 'wamid.IMG', timestamp: '1760000000', type: 'image',
            image: { id: 'media-123', mime_type: 'image/jpeg', caption: 'la factura' } }],
        } }] }],
      };
      const crudo = JSON.stringify(payload);
      const [evento] = provider.parsearWebhook(firmar(crudo), crudo, CANAL);
      expect(evento).toMatchObject({
        tipo: 'adjunto',
        // Meta entrega un id, no una URL: la descarga es un paso aparte.
        adjunto_url: 'media-123',
        adjunto_mime: 'image/jpeg',
        texto: 'la factura',
      });
    });

    it('traduce los acuses de entrega', () => {
      const payload = {
        entry: [{ changes: [{ value: {
          metadata: { phone_number_id: '109876543210' },
          statuses: [{ id: 'wamid.ABC', status: 'read', timestamp: '1760000100' }],
        } }] }],
      };
      const crudo = JSON.stringify(payload);
      expect(provider.parsearWebhook(firmar(crudo), crudo, CANAL)[0]).toMatchObject({
        clase: 'estado', id_externo: 'wamid.ABC', estado: 'leido',
      });
    });

    it('un estado desconocido no rompe: cae a enviado', () => {
      const payload = {
        entry: [{ changes: [{ value: {
          metadata: { phone_number_id: '109876543210' },
          statuses: [{ id: 'x', status: 'inventado' }],
        } }] }],
      };
      const crudo = JSON.stringify(payload);
      expect(provider.parsearWebhook(firmar(crudo), crudo, CANAL)[0]).toMatchObject({ estado: 'enviado' });
    });

    it('un POST con varios eventos devuelve todos', () => {
      const payload = {
        entry: [{ changes: [{ value: {
          metadata: { phone_number_id: '109876543210' },
          messages: [{ from: '1', id: 'm1', type: 'text', text: { body: 'a' } }],
          statuses: [{ id: 'm0', status: 'delivered' }],
        } }] }],
      };
      const crudo = JSON.stringify(payload);
      expect(provider.parsearWebhook(firmar(crudo), crudo, CANAL)).toHaveLength(2);
    });

    it('un payload vacío no genera eventos', () => {
      const crudo = JSON.stringify({ entry: [] });
      expect(provider.parsearWebhook(firmar(crudo), crudo, CANAL)).toEqual([]);
    });
  });

  describe('verificarSuscripcion', () => {
    it('devuelve el challenge cuando el token coincide', () => {
      expect(MetaCloudProvider.verificarSuscripcion('subscribe', 'tok', 'desafio-123', 'tok')).toBe('desafio-123');
    });

    it('rechaza un token distinto', () => {
      expect(() => MetaCloudProvider.verificarSuscripcion('subscribe', 'otro', 'x', 'tok')).toThrow(UnauthorizedException);
    });

    it('rechaza un modo distinto de subscribe', () => {
      expect(() => MetaCloudProvider.verificarSuscripcion('unsubscribe', 'tok', 'x', 'tok')).toThrow(UnauthorizedException);
    });

    it('rechaza si el canal no tiene token configurado', () => {
      expect(() => MetaCloudProvider.verificarSuscripcion('subscribe', 'tok', 'x', null)).toThrow(UnauthorizedException);
    });
  });

  describe('envío', () => {
    afterEach(() => jest.restoreAllMocks());

    it('arma el cuerpo de texto que espera Meta', async () => {
      const fetchMock = jest.fn().mockResolvedValue({
        ok: true, json: () => Promise.resolve({ messages: [{ id: 'wamid.OUT' }] }),
      });
      global.fetch = fetchMock as never;

      const r = await provider.enviarTexto(CANAL, '595981999888', 'hola');

      expect(r).toEqual({ id_externo: 'wamid.OUT', estado: 'encolado' });
      const [url, opciones] = fetchMock.mock.calls[0];
      expect(url).toContain('/109876543210/messages');
      expect(opciones.headers.Authorization).toBe('Bearer EAAG-token');
      expect(JSON.parse(opciones.body)).toMatchObject({
        messaging_product: 'whatsapp', to: '595981999888', type: 'text',
      });
    });

    /** Meta acusa recibo, no entrega: el estado real llega por webhook. */
    it('un envío aceptado queda "encolado", no "enviado"', async () => {
      global.fetch = jest.fn().mockResolvedValue({
        ok: true, json: () => Promise.resolve({ messages: [{ id: 'x' }] }),
      }) as never;
      expect((await provider.enviarTexto(CANAL, '1', 'h')).estado).toBe('encolado');
    });

    it('un rechazo de Meta devuelve el motivo, no una excepción', async () => {
      global.fetch = jest.fn().mockResolvedValue({
        ok: false, status: 400,
        json: () => Promise.resolve({ error: { message: 'Template not found' } }),
      }) as never;

      expect(await provider.enviarTexto(CANAL, '1', 'h')).toMatchObject({
        estado: 'rechazado', error: 'Template not found',
      });
    });

    /** Si Meta no responde, el mensaje no salió pero la conversación sigue viva. */
    it('una caída de red devuelve rechazado con el error', async () => {
      global.fetch = jest.fn().mockRejectedValue(new Error('ECONNREFUSED')) as never;
      expect(await provider.enviarTexto(CANAL, '1', 'h')).toMatchObject({
        estado: 'rechazado', error: 'ECONNREFUSED',
      });
    });

    it('sin access_token no intenta llamar', async () => {
      const fetchMock = jest.fn();
      global.fetch = fetchMock as never;
      const r = await provider.enviarTexto({ ...CANAL, credenciales: {} }, '1', 'h');
      expect(r.estado).toBe('rechazado');
      expect(fetchMock).not.toHaveBeenCalled();
    });

    it('la plantilla sin variables no manda components vacíos', async () => {
      const fetchMock = jest.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
      global.fetch = fetchMock as never;
      await provider.enviarPlantilla(CANAL, '1', 'bienvenida', 'es', []);
      expect(JSON.parse(fetchMock.mock.calls[0][1].body).template.components).toBeUndefined();
    });

    it('un PDF va como document y una foto como image', async () => {
      const fetchMock = jest.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
      global.fetch = fetchMock as never;
      await provider.enviarAdjunto(CANAL, '1', 'http://x/f.pdf', 'application/pdf');
      await provider.enviarAdjunto(CANAL, '1', 'http://x/f.jpg', 'image/jpeg');
      expect(JSON.parse(fetchMock.mock.calls[0][1].body).type).toBe('document');
      expect(JSON.parse(fetchMock.mock.calls[1][1].body).type).toBe('image');
    });
  });
});
