import { IngressService, extractConversationId, redactSensitiveHeaders } from './ingress.service';
import { EngineStatus } from '../../engine/interfaces/whatsapp-engine.interface';
import { createHmac } from 'node:crypto';

function deps(overrides: Record<string, unknown> = {}) {
  return {
    instances: {
      resolve: jest.fn().mockResolvedValue({
        id: 'chatwoot:acct1',
        pluginId: 'chatwoot',
        instanceId: 'acct1',
        secret: 's',
        enabled: true,
        sessionScope: 'sess-1',
        verifyToken: null,
      }),
    },
    manifestRoute: jest.fn().mockReturnValue({
      route: 'chatwoot',
      mode: 'async',
      verify: 'core',
      maxBodyBytes: 1024,
      signature: { scheme: 'none' },
      dedupHeader: 'x-delivery',
    }),
    events: { recordOrSkip: jest.fn().mockResolvedValue(true) },
    enqueue: jest.fn().mockResolvedValue(undefined),
    now: () => 0,
    ...overrides,
  };
}

describe('redactSensitiveHeaders (persisted ingress payloads)', () => {
  it('redacts credential and signature headers, keeps the rest verbatim', () => {
    const out = redactSensitiveHeaders({
      authorization: 'Bearer eyJhbGciOi',
      'x-hub-signature-256': 'sha256=abc',
      cookie: 'session=secret',
      'x-delivery': 'd1',
      'content-type': 'application/json',
    });
    expect(out.authorization).toBe('[redacted]');
    expect(out['x-hub-signature-256']).toBe('[redacted]');
    expect(out.cookie).toBe('[redacted]');
    expect(out['x-delivery']).toBe('d1');
    expect(out['content-type']).toBe('application/json');
  });

  it('the persisted payload carries redacted headers (wiring, not just the helper)', async () => {
    const recordOrSkip = jest.fn().mockResolvedValue(true);
    const d = deps();
    d.events.recordOrSkip = recordOrSkip;
    const svc = new IngressService(d);
    await svc.handle({
      pluginId: 'chatwoot',
      instanceId: 'acct1',
      route: 'chatwoot',
      method: 'POST',
      headers: { 'x-delivery': 'd1', authorization: 'Bearer tok' },
      query: {},
      rawBody: '{}',
    });
    // jest's mock.calls typing erodes through the deps() override — one explicit cast at the
    // boundary beats a chain of eslint suppressions.
    const calls = recordOrSkip.mock.calls as unknown as [[{ payload: { headers: Record<string, string> } }]];
    const recorded = calls[0][0];
    expect(recorded.payload.headers.authorization).toBe('[redacted]');
    expect(recorded.payload.headers['x-delivery']).toBe('d1');
  });
});

describe('IngressService.handle', () => {
  const req = {
    pluginId: 'chatwoot',
    instanceId: 'acct1',
    route: 'chatwoot',
    method: 'POST',
    headers: { 'x-delivery': 'd1' },
    query: {},
    rawBody: '{}',
  };

  it('verifies, persists, enqueues, and fast-acks 202', async () => {
    const d = deps();
    const svc = new IngressService(d);
    const res = await svc.handle(req);
    expect(d.events.recordOrSkip).toHaveBeenCalled();
    expect(d.enqueue).toHaveBeenCalledWith(expect.objectContaining({ deliveryId: 'd1', method: 'POST' }), 'd1');
    expect(res.status).toBe(202);
  });

  it('uses the signed webhook-id as the default Standard Webhooks dedup key', async () => {
    const rawKey = Buffer.from('0123456789abcdef0123456789abcdef', 'hex');
    const secret = `v1,whsec_${rawKey.toString('base64')}`;
    const body = '{}';
    const timestamp = '1000';
    const webhookId = 'msg_signed_1';
    const signature = 'v1,' + createHmac('sha256', rawKey).update(`${webhookId}.${timestamp}.${body}`).digest('base64');
    const d = deps({
      instances: {
        resolve: jest.fn().mockResolvedValue({
          id: 'chatwoot:acct1',
          pluginId: 'chatwoot',
          instanceId: 'acct1',
          secret,
          enabled: true,
          sessionScope: 'sess-1',
          verifyToken: null,
        }),
      },
      manifestRoute: jest.fn().mockReturnValue({
        route: 'chatwoot',
        mode: 'async',
        verify: 'core',
        maxBodyBytes: 1024,
        signature: { scheme: 'standard-webhooks' },
      }),
      now: () => 1_000_000,
    });

    const res = await new IngressService(d).handle({
      ...req,
      rawBody: body,
      headers: {
        'webhook-id': webhookId,
        'webhook-timestamp': timestamp,
        'webhook-signature': signature,
      },
    });

    expect(res.status).toBe(202);
    expect(d.events.recordOrSkip).toHaveBeenCalledWith(expect.objectContaining({ providerDeliveryId: webhookId }));
    expect(d.enqueue).toHaveBeenCalledWith(expect.objectContaining({ deliveryId: webhookId }), webhookId);
  });

  it('short-circuits a duplicate delivery with 200 and no enqueue', async () => {
    const d = deps({ events: { recordOrSkip: jest.fn().mockResolvedValue(false) } });
    const svc = new IngressService(d);
    const res = await svc.handle(req);
    expect(d.enqueue).not.toHaveBeenCalled();
    expect(res.status).toBe(200);
  });

  it('rejects an oversized body with 413 before any dedup or enqueue', async () => {
    const d = deps({
      manifestRoute: jest.fn().mockReturnValue({
        route: 'chatwoot',
        mode: 'async',
        verify: 'core',
        maxBodyBytes: 1,
        signature: { scheme: 'none' },
        dedupHeader: 'x-delivery',
      }),
    });
    const svc = new IngressService(d);
    const res = await svc.handle(req);
    expect(res.status).toBe(413);
    expect(d.events.recordOrSkip).not.toHaveBeenCalled();
  });

  // `Buffer.byteLength(rawBody) > route.maxBodyBytes` is always false when the manifest omits the
  // field, so a route declaring no cap silently enforced none — the 413 the published contract
  // promises was inert, and every accepted delivery is persisted with the body stored twice. A
  // manifest that forgets one field must not turn a route into an unbounded write amplifier.
  it.each([
    ['omitted', undefined],
    ['non-numeric', 'lots' as unknown as number],
    ['negative', -1],
  ])('falls back to a real cap when maxBodyBytes is %s', async (_label, maxBodyBytes) => {
    const d = deps({
      manifestRoute: jest.fn().mockReturnValue({
        route: 'chatwoot',
        mode: 'async',
        verify: 'core',
        maxBodyBytes,
        signature: { scheme: 'none' },
        dedupHeader: 'x-delivery',
      }),
    });
    const svc = new IngressService(d);
    const huge = { ...req, rawBody: 'x'.repeat(64 * 1024 * 1024) };
    const res = await svc.handle(huge);

    expect(res.status).toBe(413);
    expect(d.events.recordOrSkip).not.toHaveBeenCalled();
  });

  /**
   * The guard replaced `bodyLen > route.maxBodyBytes` with a typed check, and `>` COERCES: a manifest
   * is third-party JSON with no runtime validation, so a quoted number (`"1024"`) enforced a real cap
   * before and silently lost it to the much larger process-wide fallback after. The value is usable —
   * the point of the guard was to catch values that are not.
   */
  it('honours a numeric string cap rather than falling back past it', async () => {
    const d = deps({
      manifestRoute: jest.fn().mockReturnValue({
        route: 'chatwoot',
        mode: 'async',
        verify: 'core',
        maxBodyBytes: '1024' as unknown as number,
        signature: { scheme: 'none' },
        dedupHeader: 'x-delivery',
      }),
    });
    const res = await new IngressService(d).handle({ ...req, rawBody: 'x'.repeat(2048) });

    expect(res.status).toBe(413);
    expect(d.events.recordOrSkip).not.toHaveBeenCalled();
  });

  // Negative twin: the coercion must not swallow the cap it declares.
  it('accepts a body inside a numeric string cap', async () => {
    const d = deps({
      manifestRoute: jest.fn().mockReturnValue({
        route: 'chatwoot',
        mode: 'async',
        verify: 'core',
        maxBodyBytes: '1024' as unknown as number,
        signature: { scheme: 'none' },
        dedupHeader: 'x-delivery',
      }),
    });
    const res = await new IngressService(d).handle({ ...req, rawBody: 'x'.repeat(100) });

    expect(res.status).not.toBe(413);
  });

  /**
   * Zero is a cap, not a missing value: it admits only an empty body, which is what a verification
   * callback sends. Treating it as "unusable" handed such a route the process-wide limit instead —
   * a change in the permissive direction, on the one route shape that asked to accept nothing.
   * A SMALL body is what discriminates: any body is over a cap of 0, but well under the fallback.
   */
  it('treats a zero cap as strict rather than missing', async () => {
    const d = deps({
      manifestRoute: jest.fn().mockReturnValue({
        route: 'chatwoot',
        mode: 'async',
        verify: 'core',
        maxBodyBytes: 0,
        signature: { scheme: 'none' },
        dedupHeader: 'x-delivery',
      }),
    });
    const res = await new IngressService(d).handle({ ...req, rawBody: 'x' });

    expect(res.status).toBe(413);
    expect(d.events.recordOrSkip).not.toHaveBeenCalled();
  });

  /**
   * The `negative` case above uses a huge body, which is over BOTH a -1 cap and the fallback — so it
   * cannot tell "fell back" from "used -1 as the cap". An empty body separates them: it is over -1
   * and inside any real cap. Without this, dropping the `>= 0` guard changes nothing observable.
   */
  it('does not use a negative cap as the limit', async () => {
    const d = deps({
      manifestRoute: jest.fn().mockReturnValue({
        route: 'chatwoot',
        mode: 'async',
        verify: 'core',
        maxBodyBytes: -1,
        signature: { scheme: 'none' },
        dedupHeader: 'x-delivery',
      }),
    });
    const res = await new IngressService(d).handle({ ...req, rawBody: '' });

    expect(res.status).not.toBe(413);
  });

  it('still admits the empty body a zero cap exists to allow', async () => {
    const d = deps({
      manifestRoute: jest.fn().mockReturnValue({
        route: 'chatwoot',
        mode: 'async',
        verify: 'core',
        maxBodyBytes: 0,
        signature: { scheme: 'none' },
        dedupHeader: 'x-delivery',
      }),
    });
    const res = await new IngressService(d).handle({ ...req, rawBody: '' });

    expect(res.status).not.toBe(413);
  });

  it('rejects a bad signature with 401 before any dedup or enqueue', async () => {
    const d = deps({
      manifestRoute: jest.fn().mockReturnValue({
        route: 'chatwoot',
        mode: 'async',
        verify: 'core',
        maxBodyBytes: 1024,
        signature: { scheme: 'hmac-sha256', header: 'x-sig', prefix: 'sha256=' },
        dedupHeader: 'x-delivery',
      }),
    });
    const svc = new IngressService(d);
    const res = await svc.handle({ ...req, headers: { 'x-delivery': 'd1', 'x-sig': 'sha256=deadbeef' } });
    expect(res.status).toBe(401);
    expect(d.events.recordOrSkip).not.toHaveBeenCalled();
    expect(d.enqueue).not.toHaveBeenCalled();
  });

  it('404s for an unknown or disabled instance', async () => {
    const d = deps({ instances: { resolve: jest.fn().mockResolvedValue(null) } });
    const svc = new IngressService(d);
    expect((await svc.handle(req)).status).toBe(404);
  });

  it('404s for a disabled instance', async () => {
    const d = deps({
      instances: {
        resolve: jest.fn().mockResolvedValue({
          id: 'chatwoot:acct1',
          pluginId: 'chatwoot',
          instanceId: 'acct1',
          secret: 's',
          enabled: false,
          sessionScope: null,
          verifyToken: null,
        }),
      },
    });
    const svc = new IngressService(d);
    expect((await svc.handle(req)).status).toBe(404);
  });

  it('answers a GET challenge handshake host-side without enqueuing', async () => {
    const d = deps({
      instances: {
        resolve: jest.fn().mockResolvedValue({
          id: 'meta:acct1',
          pluginId: 'meta',
          instanceId: 'acct1',
          secret: 's',
          enabled: true,
          sessionScope: null,
          verifyToken: 'vtok',
        }),
      },
      manifestRoute: jest.fn().mockReturnValue({
        route: 'meta',
        mode: 'async',
        verify: 'core',
        maxBodyBytes: 1024,
        signature: { scheme: 'none' },
        challenge: { method: 'GET', tokenParam: 'hub.verify_token', echoParam: 'hub.challenge' },
      }),
    });
    const svc = new IngressService(d);
    const res = await svc.handle({
      pluginId: 'meta',
      instanceId: 'acct1',
      route: 'meta',
      method: 'GET',
      headers: {},
      query: { 'hub.verify_token': 'vtok', 'hub.challenge': '12345' },
      rawBody: '',
    });
    expect(res.status).toBe(200);
    expect(res.body).toBe('12345');
    expect(d.enqueue).not.toHaveBeenCalled();
  });

  it('rejects a GET challenge with the wrong verify token', async () => {
    const d = deps({
      instances: {
        resolve: jest.fn().mockResolvedValue({
          id: 'meta:acct1',
          pluginId: 'meta',
          instanceId: 'acct1',
          secret: 's',
          enabled: true,
          sessionScope: null,
          verifyToken: 'vtok',
        }),
      },
      manifestRoute: jest.fn().mockReturnValue({
        route: 'meta',
        mode: 'async',
        verify: 'core',
        maxBodyBytes: 1024,
        signature: { scheme: 'none' },
        challenge: { method: 'GET', tokenParam: 'hub.verify_token', echoParam: 'hub.challenge' },
      }),
    });
    const svc = new IngressService(d);
    const res = await svc.handle({
      pluginId: 'meta',
      instanceId: 'acct1',
      route: 'meta',
      method: 'GET',
      headers: {},
      query: { 'hub.verify_token': 'wrong', 'hub.challenge': '12345' },
      rawBody: '',
    });
    expect(res.status).toBe(403);
  });

  it('rejects a GET challenge when the instance has no verify token (no match against null)', async () => {
    const d = deps({
      instances: {
        resolve: jest.fn().mockResolvedValue({
          id: 'meta:acct1',
          pluginId: 'meta',
          instanceId: 'acct1',
          secret: 's',
          enabled: true,
          sessionScope: null,
          verifyToken: null,
        }),
      },
      manifestRoute: jest.fn().mockReturnValue({
        route: 'meta',
        mode: 'async',
        verify: 'core',
        maxBodyBytes: 1024,
        signature: { scheme: 'none' },
        challenge: { method: 'GET', tokenParam: 'hub.verify_token', echoParam: 'hub.challenge' },
      }),
    });
    const svc = new IngressService(d);
    const res = await svc.handle({
      pluginId: 'meta',
      instanceId: 'acct1',
      route: 'meta',
      method: 'GET',
      headers: {},
      query: { 'hub.verify_token': '', 'hub.challenge': 'x' },
      rawBody: '',
    });
    expect(res.status).toBe(403);
  });

  it('404s for an unknown route', async () => {
    const d = deps({ manifestRoute: jest.fn().mockReturnValue(undefined) });
    const svc = new IngressService(d);
    expect((await svc.handle(req)).status).toBe(404);
  });

  it('derives a DETERMINISTIC delivery id from the body when the dedup header is absent', async () => {
    // A random UUID here would defeat both persist-dedup and BullMQ jobId idempotency, so a provider
    // retry of the same body must produce the SAME id, and a different body a DIFFERENT id.
    const d = deps();
    const svc = new IngressService(d);
    const res = await svc.handle({ ...req, headers: {} });
    expect(res.status).toBe(202);
    const [jobData, jobId] = d.enqueue.mock.calls[0] as [{ deliveryId: string }, string];
    expect(typeof jobId).toBe('string');
    expect(jobId.length).toBeGreaterThan(0);
    expect(jobData.deliveryId).toBe(jobId);

    // same body → same id (retry dedups)
    const d2 = deps();
    await new IngressService(d2).handle({ ...req, headers: {} });
    expect((d2.enqueue.mock.calls[0] as [unknown, string])[1]).toBe(jobId);

    // different body → different id
    const d3 = deps();
    await new IngressService(d3).handle({ ...req, headers: {}, rawBody: '{"a":1}' });
    expect((d3.enqueue.mock.calls[0] as [unknown, string])[1]).not.toBe(jobId);
  });
});

describe('extractConversationId', () => {
  it('returns undefined when no spec is declared', () => {
    expect(extractConversationId(undefined, {}, '{}')).toBeUndefined();
  });

  it('reads a declared header (case-insensitive)', () => {
    expect(extractConversationId({ header: 'X-Conv' }, { 'x-conv': 'c1' }, '{}')).toBe('c1');
  });

  it('reads a JSON pointer into the body', () => {
    expect(extractConversationId({ jsonPointer: '/conversation/id' }, {}, '{"conversation":{"id":42}}')).toBe('42');
  });

  it('returns undefined on a malformed body without throwing', () => {
    expect(extractConversationId({ jsonPointer: '/a/b' }, {}, 'not json')).toBeUndefined();
  });
});

describe('IngressService.handle — response contract', () => {
  const baseReq = {
    pluginId: 'p',
    instanceId: 'i1',
    route: 'send-sms',
    method: 'POST',
    headers: { 'x-delivery': 'd1' },
    query: {} as Record<string, string>,
    rawBody: '{}',
  };

  function depsWith(overrides: Record<string, unknown> = {}) {
    return {
      instances: {
        resolve: jest.fn().mockResolvedValue({
          id: 'p:i1',
          pluginId: 'p',
          instanceId: 'i1',
          secret: 's',
          enabled: true,
          sessionScope: 'sess-1',
          verifyToken: null,
        }),
      },
      manifestRoute: jest.fn().mockReturnValue({
        route: 'send-sms',
        mode: 'async',
        verify: 'core',
        maxBodyBytes: 1024,
        signature: { scheme: 'none' },
        dedupHeader: 'x-delivery',
      }),
      events: { recordOrSkip: jest.fn().mockResolvedValue(true) },
      enqueue: jest.fn().mockResolvedValue(undefined),
      log: jest.fn(),
      now: () => 0,
      ...overrides,
    };
  }

  it('rejects 503 on a dead session BEFORE dedup or enqueue (no dedup trap)', async () => {
    const d = depsWith({
      sessionStatus: jest.fn().mockReturnValue(undefined),
      manifestRoute: jest.fn().mockReturnValue({
        route: 'send-sms',
        mode: 'async',
        verify: 'core',
        maxBodyBytes: 1024,
        signature: { scheme: 'none' },
        dedupHeader: 'x-delivery',
        response: { preflight: [{ type: 'session-alive' }] },
      }),
    });
    const res = await new IngressService(d).handle(baseReq);
    expect(res.status).toBe(503);
    expect(d.events.recordOrSkip).not.toHaveBeenCalled();
    expect(d.enqueue).not.toHaveBeenCalled();
    expect(d.log).toHaveBeenCalledWith(
      'ingress_preflight_rejected',
      expect.objectContaining({ status: 503, route: 'send-sms' }),
    );
  });

  it('passes a READY session through to ack + enqueue', async () => {
    const d = depsWith({
      sessionStatus: jest.fn().mockReturnValue(EngineStatus.READY),
      manifestRoute: jest.fn().mockReturnValue({
        route: 'send-sms',
        mode: 'async',
        verify: 'core',
        maxBodyBytes: 1024,
        signature: { scheme: 'none' },
        dedupHeader: 'x-delivery',
        response: { preflight: [{ type: 'session-alive' }] },
      }),
    });
    const res = await new IngressService(d).handle(baseReq);
    expect(res.status).toBe(202);
    expect(d.enqueue).toHaveBeenCalled();
  });

  it('renders a declared ack status/body/headers', async () => {
    const d = depsWith({
      manifestRoute: jest.fn().mockReturnValue({
        route: 'send-sms',
        mode: 'async',
        verify: 'core',
        maxBodyBytes: 1024,
        signature: { scheme: 'none' },
        dedupHeader: 'x-delivery',
        response: { ack: { status: 200, body: '{"ok":true}', headers: { 'content-type': 'application/json' } } },
      }),
    });
    const res = await new IngressService(d).handle(baseReq);
    expect(res.status).toBe(200);
    expect(res.body).toBe('{"ok":true}');
    expect(res.headers).toEqual({ 'content-type': 'application/json' });
  });

  it('returns the ack for a response route WITHOUT awaiting a slow enqueue', async () => {
    let resolveEnqueue: () => void;
    const enqueuePromise = new Promise<unknown>(resolve => {
      // Promise resolve requires an argument; wrap it so resolveEnqueue stays a 0-arg () => void.
      resolveEnqueue = () => resolve(undefined);
    });
    const d = depsWith({
      enqueue: jest.fn().mockReturnValue(enqueuePromise),
      manifestRoute: jest.fn().mockReturnValue({
        route: 'send-sms',
        mode: 'async',
        verify: 'core',
        maxBodyBytes: 1024,
        signature: { scheme: 'none' },
        dedupHeader: 'x-delivery',
        response: { ack: { status: 200 } },
      }),
    });
    const res = await new IngressService(d).handle(baseReq);
    expect(res.status).toBe(200); // ack returned before enqueue resolved
    expect(d.enqueue).toHaveBeenCalled();
    resolveEnqueue!();
  });

  it('survives a rejecting enqueue on a response route (defensive .catch, no unhandled rejection)', async () => {
    const d = depsWith({
      log: jest.fn(),
      enqueue: jest.fn().mockRejectedValue(new Error('boom')),
      manifestRoute: jest.fn().mockReturnValue({
        route: 'send-sms',
        mode: 'async',
        verify: 'core',
        maxBodyBytes: 1024,
        signature: { scheme: 'none' },
        dedupHeader: 'x-delivery',
        response: { ack: { status: 200 } },
      }),
    });
    const res = await new IngressService(d).handle(baseReq);
    expect(res.status).toBe(200); // ack returned despite the rejected enqueue
    // The rejected enqueue is caught + logged, not thrown. Flush microtasks so the .catch handler runs.
    await new Promise(resolve => setImmediate(resolve));
    expect(d.log).toHaveBeenCalledWith(
      'ingress_enqueue_unhandled',
      expect.objectContaining({ deliveryId: 'd1', error: 'boom' }),
    );
  });

  it('keeps the duplicate path as 200 "duplicate" regardless of a declared ack', async () => {
    const d = depsWith({
      events: { recordOrSkip: jest.fn().mockResolvedValue(false) },
      manifestRoute: jest.fn().mockReturnValue({
        route: 'send-sms',
        mode: 'async',
        verify: 'core',
        maxBodyBytes: 1024,
        signature: { scheme: 'none' },
        dedupHeader: 'x-delivery',
        response: { ack: { status: 200, body: '{"ok":true}' } },
      }),
    });
    const res = await new IngressService(d).handle(baseReq);
    expect(res.status).toBe(200);
    expect(res.body).toBe('duplicate');
    expect(d.enqueue).not.toHaveBeenCalled();
  });
});
