// archiver v8 is ESM-only (pulled in transitively via @Global StorageModule); stub for ts-jest CJS.
jest.mock('archiver', () => ({ TarArchive: jest.fn() }));

import http from 'http';
import crypto from 'crypto';
import { AddressInfo } from 'net';
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
import { applyGlobalValidation } from './../src/config/app-validation';
import { AuthService } from './../src/modules/auth/auth.service';
import { ApiKeyRole } from './../src/modules/auth/entities/api-key.entity';
import { Session } from './../src/modules/session/entities/session.entity';
import { WebhookService } from './../src/modules/webhook/webhook.service';
import { HookManager } from './../src/core/hooks';

/**
 * End-to-end coverage for the webhooks module across the seam the unit specs can't reach: the REST
 * surface (CRUD + auth + validation, with the global ValidationPipe), persistence, and real
 * HMAC-signed HTTP delivery to a live local receiver. Dispatch is the one thing the WhatsApp socket
 * would normally trigger, so we call WebhookService.dispatch() directly - that boundary isn't
 * webhook logic.
 *
 * SSRF protection is ON by default and would reject the 127.0.0.1 receiver at both registration and
 * delivery, so the suite runs with WEBHOOK_SSRF_PROTECT=false (one test flips it back on locally).
 */
describe('Webhooks (e2e)', () => {
  let app: INestApplication<App>;
  let webhookService: WebhookService;
  let sessionRepo: Repository<Session>;
  let apiKey: string;
  let viewerKey: string;
  let received: Array<{ headers: http.IncomingHttpHeaders; raw: string; body: Record<string, unknown> }>;
  let receiver: http.Server;
  let receiverUrl: string;
  const prevSsrf = process.env.WEBHOOK_SSRF_PROTECT;
  const prevMaxPerSession = process.env.WEBHOOK_MAX_PER_SESSION;
  const prevMediaInlineMax = process.env.WEBHOOK_MEDIA_INLINE_MAX_BYTES;

  // Webhooks carry a CASCADE foreign key to a session row, and dispatch looks them up by sessionId.
  // Persisting one real session per test both satisfies the FK and isolates each case (and prior
  // runs) from the others, since dispatch and the session-scoped routes only see that session's id.
  let sessionSeq = 0;
  const nextSession = async (): Promise<string> => {
    const session = await sessionRepo.save(sessionRepo.create({ name: `e2e-webhooks-${Date.now()}-${sessionSeq++}` }));
    return session.id;
  };

  const waitFor = async (predicate: () => boolean, timeoutMs = 1000): Promise<void> => {
    const start = Date.now();
    while (!predicate()) {
      if (Date.now() - start > timeoutMs) throw new Error('timed out waiting for condition');
      await new Promise(r => setTimeout(r, 10));
    }
  };

  // Create a webhook via the REST API (exercises CreateWebhookDto + filter validation) and return the
  // response DTO. Defaults to the receiver URL + a subscribe-all event so dispatch tests can refine
  // behaviour purely through filters/overrides.
  const createWebhook = async (
    session: string,
    overrides: Record<string, unknown> = {},
  ): Promise<Record<string, unknown>> => {
    const res = await request(app.getHttpServer())
      .post(`/api/sessions/${session}/webhooks`)
      .set('X-API-Key', apiKey)
      .send({ url: receiverUrl, events: ['*'], ...overrides })
      .expect(201);
    return res.body as Record<string, unknown>;
  };

  beforeAll(async () => {
    process.env.WEBHOOK_SSRF_PROTECT = 'false';
    // Small bounds so the payload-bounds cases below exercise them without bulky setups. Read once
    // at module load, so they must be set before AppModule compiles.
    process.env.WEBHOOK_MAX_PER_SESSION = '3';
    process.env.WEBHOOK_MEDIA_INLINE_MAX_BYTES = '1024';

    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    applyGlobalValidation(app);
    await app.init();

    webhookService = app.get(WebhookService);
    sessionRepo = app.get(getRepositoryToken(Session, 'data'));

    // Mint real keys so the suite doesn't depend on seed/DB state; ADMIN covers the OPERATOR routes.
    const authService = app.get(AuthService);
    apiKey = (await authService.createApiKey({ name: 'e2e-admin', role: ApiKeyRole.ADMIN })).rawKey;
    viewerKey = (await authService.createApiKey({ name: 'e2e-viewer', role: ApiKeyRole.VIEWER })).rawKey;

    received = [];
    receiver = http.createServer((req, res) => {
      let raw = '';
      req.on('data', chunk => (raw += chunk));
      req.on('end', () => {
        received.push({ headers: req.headers, raw, body: JSON.parse(raw || '{}') as Record<string, unknown> });
        res.writeHead(200).end();
      });
    });
    await new Promise<void>(resolve => receiver.listen(0, '127.0.0.1', resolve));
    receiverUrl = `http://127.0.0.1:${(receiver.address() as AddressInfo).port}/hook`;
  });

  afterAll(async () => {
    await new Promise<void>(resolve => receiver.close(() => resolve()));
    if (prevSsrf === undefined) delete process.env.WEBHOOK_SSRF_PROTECT;
    else process.env.WEBHOOK_SSRF_PROTECT = prevSsrf;
    if (prevMaxPerSession === undefined) delete process.env.WEBHOOK_MAX_PER_SESSION;
    else process.env.WEBHOOK_MAX_PER_SESSION = prevMaxPerSession;
    if (prevMediaInlineMax === undefined) delete process.env.WEBHOOK_MEDIA_INLINE_MAX_BYTES;
    else process.env.WEBHOOK_MEDIA_INLINE_MAX_BYTES = prevMediaInlineMax;
    try {
      await app?.close();
    } catch {
      /* ignore teardown-only multi-datasource quirk */
    }
  });

  beforeEach(() => {
    received = [];
  });

  // ── CRUD over the REST surface ────────────────────────────────────

  describe('CRUD over REST', () => {
    it('creates a webhook and never leaks the secret or headers in the response', async () => {
      const session = await nextSession();
      const dto = await createWebhook(session, {
        secret: 'top-secret-0123456789',
        headers: { 'X-Custom': 'v' },
        filters: { conditions: [{ field: 'sender', operator: 'is', value: ['a@c.us'] }] },
      });

      expect(dto.id).toBeDefined();
      expect(dto.sessionId).toBe(session);
      expect(dto.active).toBe(true);
      // Write-only fields must never appear in a webhook-route response (nor in the infra
      // export, which omits them from webhook rows — covered in infra-data.controller.spec.ts).
      expect(dto.secret).toBeUndefined();
      expect(dto.headers).toBeUndefined();
    });

    it('returns 404 when creating a webhook for a session that does not exist', async () => {
      await request(app.getHttpServer())
        .post('/api/sessions/no-such-session/webhooks')
        .set('X-API-Key', apiKey)
        .send({ url: receiverUrl, events: ['*'] })
        .expect(404);
    });

    it('lists webhooks for a session', async () => {
      const session = await nextSession();
      const created = await createWebhook(session);

      const res = await request(app.getHttpServer())
        .get(`/api/sessions/${session}/webhooks`)
        .set('X-API-Key', apiKey)
        .expect(200);

      const list = res.body as Array<{ id: string }>;
      expect(list).toHaveLength(1);
      expect(list[0].id).toBe(created.id);
    });

    it('gets a webhook by id, and returns 404 for an unknown id', async () => {
      const session = await nextSession();
      const created = await createWebhook(session);

      await request(app.getHttpServer())
        .get(`/api/sessions/${session}/webhooks/${created.id as string}`)
        .set('X-API-Key', apiKey)
        .expect(200)
        .expect(res => {
          if ((res.body as { id: string }).id !== created.id) throw new Error('wrong webhook returned');
        });

      await request(app.getHttpServer())
        .get(`/api/sessions/${session}/webhooks/00000000-0000-0000-0000-000000000000`)
        .set('X-API-Key', apiKey)
        .expect(404);
    });

    it('updates fields and persists them', async () => {
      const session = await nextSession();
      const created = await createWebhook(session);

      const res = await request(app.getHttpServer())
        .put(`/api/sessions/${session}/webhooks/${created.id as string}`)
        .set('X-API-Key', apiKey)
        .send({ events: ['message.received', 'session.status'], active: false })
        .expect(200);

      const updated = res.body as { events: string[]; active: boolean };
      expect(updated.events).toEqual(['message.received', 'session.status']);
      expect(updated.active).toBe(false);
    });

    it('lists the webhook across all sessions via GET /api/webhooks', async () => {
      const session = await nextSession();
      const created = await createWebhook(session);

      const res = await request(app.getHttpServer()).get('/api/webhooks').set('X-API-Key', apiKey).expect(200);

      const ids = (res.body as Array<{ id: string }>).map(w => w.id);
      expect(ids).toContain(created.id);
    });

    it('deletes a webhook (204) and then it is gone (404)', async () => {
      const session = await nextSession();
      const created = await createWebhook(session);

      await request(app.getHttpServer())
        .delete(`/api/sessions/${session}/webhooks/${created.id as string}`)
        .set('X-API-Key', apiKey)
        .expect(204);

      await request(app.getHttpServer())
        .get(`/api/sessions/${session}/webhooks/${created.id as string}`)
        .set('X-API-Key', apiKey)
        .expect(404);
    });
  });

  // ── auth boundaries ───────────────────────────────────────────────

  describe('auth', () => {
    it('rejects a request with no API key (401)', async () => {
      const session = await nextSession();
      await request(app.getHttpServer()).get(`/api/sessions/${session}/webhooks`).expect(401);
    });

    it('forbids a viewer-role key from creating a webhook (403)', async () => {
      const session = await nextSession();
      await request(app.getHttpServer())
        .post(`/api/sessions/${session}/webhooks`)
        .set('X-API-Key', viewerKey)
        .send({ url: receiverUrl })
        .expect(403);
    });
  });

  // ── registration validation ───────────────────────────────────────

  describe('registration validation', () => {
    it('rejects a URL embedding credentials (userinfo) even with SSRF protection off', async () => {
      const session = await nextSession();
      // This suite runs with WEBHOOK_SSRF_PROTECT=false, so a 400 here proves the credential
      // rejection is not tied to the SSRF flag.
      const res = await request(app.getHttpServer())
        .post(`/api/sessions/${session}/webhooks`)
        .set('X-API-Key', apiKey)
        .send({ url: 'https://user:pass@example.com/hook' })
        .expect(400);
      expect((res.body as { message: string }).message).toMatch(/must not contain credentials/);
    });

    it('rejects an internal URL with 400 when SSRF protection is on', async () => {
      const session = await nextSession();
      // Self-contained: turn protection on and clear any ambient SSRF_ALLOWED_HOSTS (a dev .env may
      // allowlist 127.0.0.1) so the assertion holds regardless of the local environment.
      const prevAllow = process.env.SSRF_ALLOWED_HOSTS;
      process.env.WEBHOOK_SSRF_PROTECT = 'true';
      process.env.SSRF_ALLOWED_HOSTS = '';
      try {
        await request(app.getHttpServer())
          .post(`/api/sessions/${session}/webhooks`)
          .set('X-API-Key', apiKey)
          .send({ url: 'http://169.254.169.254/hook' }) // link-local cloud metadata, a canonical SSRF target
          .expect(400);
      } finally {
        process.env.WEBHOOK_SSRF_PROTECT = 'false';
        if (prevAllow === undefined) delete process.env.SSRF_ALLOWED_HOSTS;
        else process.env.SSRF_ALLOWED_HOSTS = prevAllow;
      }
    });
  });

  // ── dispatch over real HTTP ───────────────────────────────────────

  describe('dispatch over real HTTP', () => {
    it('delivers a correctly HMAC-signed POST when a filter matches', async () => {
      const session = await nextSession();
      const secret = 'sig-secret-0123456789';
      await createWebhook(session, {
        secret,
        filters: { conditions: [{ field: 'sender', operator: 'is', value: ['boss@c.us'] }] },
      });

      await webhookService.dispatch(session, 'message.received', { from: 'boss@c.us', body: 'hi' });
      await waitFor(() => received.length === 1);

      const { headers, raw, body } = received[0];
      expect(headers['x-openwa-event']).toBe('message.received');
      // Verify the signature over the exact bytes that were sent, not a re-serialization.
      const expected = `sha256=${crypto.createHmac('sha256', secret).update(raw).digest('hex')}`;
      expect(headers['x-openwa-signature']).toBe(expected);
      expect((body as { data: { from: string } }).data.from).toBe('boss@c.us');
    });

    it('does not deliver when the filter does not match', async () => {
      const session = await nextSession();
      await createWebhook(session, {
        filters: { conditions: [{ field: 'sender', operator: 'is', value: ['boss@c.us'] }] },
      });

      // dispatch() awaits every per-webhook delivery attempt end-to-end in this queue-off suite
      // (Promise.allSettled over the direct deliveries, each awaiting the receiver's response), and
      // the receiver records a hit BEFORE it answers — so a wrongly-attempted delivery would already
      // be in `received` here. A fixed sleep would only slow the assertion, not strengthen it.
      await webhookService.dispatch(session, 'message.received', { from: 'spammer@c.us', body: 'spam' });
      expect(received).toHaveLength(0);
    });

    it('passes a non-message event through a message-only filter', async () => {
      const session = await nextSession();
      await createWebhook(session, {
        filters: { conditions: [{ field: 'sender', operator: 'is', value: ['nobody@c.us'] }] },
      });

      await webhookService.dispatch(session, 'session.status', { status: 'connected' });
      await waitFor(() => received.length === 1);
      expect(received[0].headers['x-openwa-event']).toBe('session.status');
    });

    it('does not deliver to an inactive webhook', async () => {
      const session = await nextSession();
      const created = await createWebhook(session);
      await request(app.getHttpServer())
        .put(`/api/sessions/${session}/webhooks/${created.id as string}`)
        .set('X-API-Key', apiKey)
        .send({ active: false })
        .expect(200);

      // Same settled-dispatch reasoning as the filter-mismatch case above: the awaited dispatch()
      // resolves only after every attempted delivery completed, so no wait is needed before asserting.
      await webhookService.dispatch(session, 'message.received', { from: 'a@c.us' });
      expect(received).toHaveLength(0);
    });

    it('drops forged reserved headers but keeps custom ones on the wire', async () => {
      const session = await nextSession();
      await createWebhook(session, {
        headers: { 'X-OpenWA-Event': 'forged', 'Content-Type': 'text/plain', 'X-Custom': 'ok' },
      });

      await webhookService.dispatch(session, 'message.received', {});
      await waitFor(() => received.length === 1);

      const { headers } = received[0];
      expect(headers['x-openwa-event']).toBe('message.received'); // system value wins, not 'forged'
      expect(headers['content-type']).toBe('application/json');
      expect(headers['x-custom']).toBe('ok'); // legitimate custom header preserved
    });

    it('keeps server identity fields on the signed body when a webhook:before hook tampers with them', async () => {
      const session = await nextSession();
      const secret = 'sig-secret-0123456789';
      await createWebhook(session, { secret });

      const hookManager = app.get(HookManager);
      const hookId = hookManager.register('e2e-tamper', 'webhook:before', ctx => {
        const data = ctx.data as { payload: Record<string, unknown> };
        return Promise.resolve({
          continue: true,
          data: {
            ...data,
            payload: {
              ...data.payload,
              event: 'forged.event',
              sessionId: 'other-session',
              timestamp: '1999-01-01T00:00:00.000Z',
            },
          },
        });
      });
      try {
        await webhookService.dispatch(session, 'message.received', { from: 'boss@c.us' });
        await waitFor(() => received.length === 1);

        const { headers, raw, body } = received[0];
        // The tampered identity fields were re-asserted to the server's values, so the body still
        // matches the headers and the signature still verifies over the exact bytes received.
        expect(body.event).toBe('message.received');
        expect(body.sessionId).toBe(session);
        expect(body.timestamp).not.toBe('1999-01-01T00:00:00.000Z');
        expect(headers['x-openwa-event']).toBe('message.received');
        const expected = `sha256=${crypto.createHmac('sha256', secret).update(raw).digest('hex')}`;
        expect(headers['x-openwa-signature']).toBe(expected);
        expect((body as { data: { from: string } }).data.from).toBe('boss@c.us'); // data stays hook-controlled
      } finally {
        hookManager.unregister(hookId);
      }
    });
  });

  // ── payload bounds (per-session cap + inline-media shedding) ──────

  describe('payload bounds', () => {
    it('rejects the (cap+1)-th webhook with 400 while the grandfathered ones keep working', async () => {
      const session = await nextSession();
      // WEBHOOK_MAX_PER_SESSION=3 for this suite (set in beforeAll).
      for (let i = 0; i < 3; i++) {
        await createWebhook(session);
      }
      const res = await request(app.getHttpServer())
        .post(`/api/sessions/${session}/webhooks`)
        .set('X-API-Key', apiKey)
        .send({ url: receiverUrl, events: ['*'] })
        .expect(400);
      expect((res.body as { message: string }).message).toMatch(/Webhook limit reached/);

      // The webhooks registered before the cap are unaffected: every one of them still receives.
      await webhookService.dispatch(session, 'message.received', { from: 'a@c.us', body: 'hi' });
      await waitFor(() => received.length === 3);
    });

    it('delivers over-threshold media as an omitted marker, signed over the exact shed bytes', async () => {
      const session = await nextSession();
      const secret = 'media-secret-0123456789';
      await createWebhook(session, { secret });

      const base64 = Buffer.alloc(2048, 11).toString('base64'); // 2048 decoded bytes > 1024 inline cap
      await webhookService.dispatch(session, 'message.received', {
        from: 'a@c.us',
        media: { mimetype: 'image/jpeg', filename: 'big.jpg', data: base64 },
      });
      await waitFor(() => received.length === 1);

      const { headers, raw, body } = received[0];
      expect((body as { data: { media: Record<string, unknown> } }).data.media).toEqual({
        mimetype: 'image/jpeg',
        filename: 'big.jpg',
        omitted: true,
        sizeBytes: 2048,
      });
      // The signature verifies over the exact marker-form bytes the receiver got.
      const expected = `sha256=${crypto.createHmac('sha256', secret).update(raw).digest('hex')}`;
      expect(headers['x-openwa-signature']).toBe(expected);
    });

    it('keeps under-threshold media inline end-to-end', async () => {
      const session = await nextSession();
      await createWebhook(session);

      const base64 = Buffer.alloc(512, 4).toString('base64'); // 512 decoded bytes < 1024 inline cap
      await webhookService.dispatch(session, 'message.received', {
        from: 'a@c.us',
        media: { mimetype: 'image/jpeg', data: base64 },
      });
      await waitFor(() => received.length === 1);

      const media = (received[0].body as { data: { media: { data?: string; omitted?: boolean } } }).data.media;
      expect(media.data).toBe(base64);
      expect(media.omitted).toBeUndefined();
    });
  });

  // ── the test endpoint ─────────────────────────────────────────────

  describe('test endpoint', () => {
    it('POST /:id/test delivers a test event to the receiver and reports success', async () => {
      const session = await nextSession();
      const created = await createWebhook(session);

      const res = await request(app.getHttpServer())
        .post(`/api/sessions/${session}/webhooks/${created.id as string}/test`)
        .set('X-API-Key', apiKey)
        .expect(200);

      expect((res.body as { success: boolean }).success).toBe(true);
      await waitFor(() => received.length === 1);
      expect(received[0].headers['x-openwa-event']).toBe('test');
    });
  });
});
