import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import Fastify, { type FastifyInstance } from 'fastify';
import rateLimit from '@fastify/rate-limit';
import type { Channel } from '@wa-ticketing/shared';

// db.ts throws without DATABASE_URL at import time; the postgres client connects lazily and these
// tests never reach a query, so a dummy is harmless.
process.env.DATABASE_URL ??= 'postgres://test:test@127.0.0.1:1/test';

const { inboundWhatsappRoutes, inboundWhatsappRateLimit, registerWebhookFormBodyParser } =
  await import('./inboundWhatsapp.js');
const { channelService } = await import('../services/channelService.js');
const { messagingService } = await import('../services/messagingService.js');

const TOKEN = '11111111-1111-4111-8111-111111111111';
const OTHER_TOKEN = '22222222-2222-4222-8222-222222222222';
const PATH = `/api/v1/inbound/whatsapp/africas-talking/${TOKEN}`;

const atChannel: Channel = {
  id: 'ch-1',
  name: 'Support',
  phone_number: '+27820000000',
  kind: 'whatsapp',
  provider: 'africas_talking',
  status: 'connected',
  created_at: '2026-01-01T00:00:00.000Z',
  last_connected_at: null,
  inbound_token: TOKEN,
};

/** Every id the handler asked about — proof that a parsed body reached the mapper. */
let dedupeChecks: string[] = [];

beforeEach(() => {
  dedupeChecks = [];
  // Object-literal service exports, stubbed in place: no DB, no module mocking machinery.
  channelService.getWhatsAppByInboundToken = async () => atChannel;
  messagingService.isDuplicate = async waId => {
    dedupeChecks.push(waId);
    // Short-circuits the handler before processInbound/downloadMedia, which would need real deps.
    return true;
  };
});

/** The production wiring: parent default rate limit, then the real route plugin under it. */
async function buildApp(parentMax = 1): Promise<FastifyInstance> {
  const app = Fastify();
  await app.register(
    async scope => {
      await scope.register(rateLimit, { max: parentMax, timeWindow: '1 minute' });
      await scope.register(inboundWhatsappRoutes);
    },
    { prefix: '/api/v1' },
  );
  await app.ready();
  return app;
}

describe('inbound WhatsApp callback — form-encoded bodies', () => {
  it('accepts application/x-www-form-urlencoded and parses it into the mapper', async () => {
    const app = await buildApp();

    const res = await app.inject({
      method: 'POST',
      url: PATH,
      headers: { 'content-type': 'application/x-www-form-urlencoded' },
      payload: 'phoneNumber=%2B27831234567&messageId=at-form-1&text=hello+there',
    });

    assert.equal(res.statusCode, 200, res.body);
    assert.deepEqual(res.json(), { ok: true, duplicate: true });
    // The handler could only have learned this id by parsing the form body.
    assert.deepEqual(dedupeChecks, ['at-form-1']);

    await app.close();
  });

  it('accepts a charset-qualified form content type', async () => {
    const app = await buildApp();

    const res = await app.inject({
      method: 'POST',
      url: PATH,
      headers: { 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8' },
      payload: 'phoneNumber=%2B27831234567&messageId=at-form-2&text=hi',
    });

    assert.equal(res.statusCode, 200, res.body);
    assert.deepEqual(dedupeChecks, ['at-form-2']);

    await app.close();
  });

  it('still accepts JSON', async () => {
    const app = await buildApp();

    const res = await app.inject({
      method: 'POST',
      url: PATH,
      payload: { phoneNumber: '+27831234567', messageId: 'at-json-1', text: 'hello' },
    });

    assert.equal(res.statusCode, 200, res.body);
    assert.deepEqual(dedupeChecks, ['at-json-1']);

    await app.close();
  });

  it('does not become a blanket parser — an unregistered content type is still 415', async () => {
    // Fastify ships parsers for JSON and text/plain, so neither proves anything here; XML is one
    // the framework genuinely has no parser for.
    const app = await buildApp();

    const res = await app.inject({
      method: 'POST',
      url: PATH,
      headers: { 'content-type': 'application/xml' },
      payload: '<callback><messageId>at-form-3</messageId></callback>',
    });

    assert.equal(res.statusCode, 415);

    await app.close();
  });

  it('keeps the 1 MiB body limit on form bodies', async () => {
    const app = await buildApp();

    const res = await app.inject({
      method: 'POST',
      url: PATH,
      headers: { 'content-type': 'application/x-www-form-urlencoded' },
      payload: `phoneNumber=%2B27831234567&messageId=at-form-4&text=${'x'.repeat(1024 * 1024 + 64)}`,
    });

    assert.equal(res.statusCode, 413);
    assert.deepEqual(dedupeChecks, []);

    await app.close();
  });

  it('rejects a form body that is not a usable message with 400, not 415', async () => {
    const app = await buildApp();

    const res = await app.inject({
      method: 'POST',
      url: PATH,
      headers: { 'content-type': 'application/x-www-form-urlencoded' },
      payload: 'phoneNumber=%2B27831234567',
    });

    assert.equal(res.statusCode, 400);

    await app.close();
  });

  it('acknowledges a form-encoded delivery receipt without storing anything', async () => {
    const app = await buildApp();

    const res = await app.inject({
      method: 'POST',
      url: PATH,
      headers: { 'content-type': 'application/x-www-form-urlencoded' },
      payload: 'status=Delivered&messageId=at-form-5&phoneNumber=%2B27831234567',
    });

    assert.equal(res.statusCode, 200, res.body);
    assert.deepEqual(res.json(), { ok: true, ignored: true, reason: 'unsupported_event' });
    assert.deepEqual(dedupeChecks, []);

    await app.close();
  });

  it('is exposed as a helper the production route actually uses', async () => {
    // Registering the helper alone must give a bare instance the same parsing behaviour.
    const app = Fastify();
    let seen: unknown = null;
    await app.register(async scope => {
      registerWebhookFormBodyParser(scope);
      scope.post('/probe', async req => {
        seen = req.body;
        return { ok: true };
      });
    });
    await app.ready();

    const res = await app.inject({
      method: 'POST',
      url: '/probe',
      headers: { 'content-type': 'application/x-www-form-urlencoded' },
      payload: 'a=1&b=two&c=%2B27831234567',
    });

    assert.equal(res.statusCode, 200, res.body);
    assert.deepEqual(seen, { a: '1', b: 'two', c: '+27831234567' });

    await app.close();
  });

  it('keeps the parser scoped — a sibling plugin does not inherit it', async () => {
    const app = Fastify();
    await app.register(async scope => {
      registerWebhookFormBodyParser(scope);
      scope.post('/withParser', async () => ({ ok: true }));
    });
    await app.register(async scope => {
      scope.post('/withoutParser', async () => ({ ok: true }));
    });
    await app.ready();

    const form = { 'content-type': 'application/x-www-form-urlencoded' };
    assert.equal((await app.inject({ method: 'POST', url: '/withParser', headers: form, payload: 'a=1' })).statusCode, 200);
    assert.equal((await app.inject({ method: 'POST', url: '/withoutParser', headers: form, payload: 'a=1' })).statusCode, 415);

    await app.close();
  });
});

describe('inbound WhatsApp callback — rate limiting', () => {
  it('is configured for a production-suitable 600 per minute', () => {
    assert.equal(inboundWhatsappRateLimit.max, 600);
    assert.equal(inboundWhatsappRateLimit.timeWindow, '1 minute');
  });

  it('keys on the channel token, not the caller IP', () => {
    const key = (token: string, ip: string) =>
      inboundWhatsappRateLimit.keyGenerator({ params: { token }, ip } as never);

    // Same channel behind two provider egress IPs shares one budget...
    assert.equal(key(TOKEN, '196.201.214.200'), key(TOKEN, '41.90.64.10'));
    // ...and two channels behind one IP do not.
    assert.notEqual(key(TOKEN, '196.201.214.200'), key(OTHER_TOKEN, '196.201.214.200'));
  });

  it('never puts the raw token in the rate-limit key', () => {
    const key = inboundWhatsappRateLimit.keyGenerator({ params: { token: TOKEN }, ip: '196.201.214.200' } as never);
    assert.ok(!key.includes(TOKEN), 'the token must not be recoverable from the limiter key');
  });

  it('still produces a stable key when the token is missing', () => {
    const key = inboundWhatsappRateLimit.keyGenerator({ params: {}, ip: '196.201.214.200' } as never);
    assert.equal(typeof key, 'string');
    assert.ok(key.length > 0);
  });

  it('gives two channel tokens separate budgets through the limiter itself', async () => {
    // The key function alone cannot prove @fastify/rate-limit actually buckets on it, and 600
    // requests per token is a slow way to find out — so reuse the real config with a small max.
    const app = Fastify();
    await app.register(async scope => {
      await scope.register(rateLimit, { max: 50, timeWindow: '1 minute' });
      scope.post(
        '/probe/:token',
        { config: { rateLimit: { ...inboundWhatsappRateLimit, max: 2 } } },
        async () => ({ ok: true }),
      );
    });
    await app.ready();

    const hit = (token: string) => app.inject({ method: 'POST', url: `/probe/${token}`, payload: {} });

    assert.equal((await hit(TOKEN)).statusCode, 200);
    assert.equal((await hit(TOKEN)).statusCode, 200);
    assert.equal((await hit(TOKEN)).statusCode, 429, 'first token must exhaust its own budget');

    // A second token is untouched by the first one's exhaustion — that is the isolation.
    assert.equal((await hit(OTHER_TOKEN)).statusCode, 200);
    assert.equal((await hit(OTHER_TOKEN)).statusCode, 200);
    assert.equal((await hit(OTHER_TOKEN)).statusCode, 429);

    // ...and the first is still exhausted, so they are genuinely separate buckets.
    assert.equal((await hit(TOKEN)).statusCode, 429);

    await app.close();
  });

  it('supersedes the parent scope default and clears the old 40/min ceiling', async () => {
    // Parent allows 1/min and the previous route ceiling was 40; a 45-deep burst must pass both.
    const app = await buildApp(1);

    const statuses: number[] = [];
    for (let i = 0; i < 45; i += 1) {
      const res = await app.inject({
        method: 'POST',
        url: PATH,
        payload: { phoneNumber: '+27831234567', messageId: `at-burst-${i}`, text: 'hi' },
      });
      statuses.push(res.statusCode);
    }

    assert.deepEqual(statuses.filter(s => s === 429), [], 'route override did not supersede the parent default');

    await app.close();
  });
});
