import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import type { Channel, WhatsAppProvider } from '@wa-ticketing/shared';
import { getWhatsAppCapabilities } from './whatsappCapabilities.js';
import type { NormalizedWhatsAppInbound } from './whatsappInboundService.js';
import type { AfricasTalkingWebhookDeps } from './africasTalkingWebhook.js';

// db.ts throws without DATABASE_URL at import time; the postgres client connects lazily, so a dummy is harmless.
process.env.DATABASE_URL ??= 'postgres://test:test@127.0.0.1:1/test';
const webhookModule = await import('./africasTalkingWebhook.js');
const {
  AfricasTalkingWebhookError,
  classifyAfricasTalkingCallback,
  handleAfricasTalkingWebhook,
  mapAfricasTalkingWebhook,
  readAfricasTalkingWaNumber,
  webhookRetryAfterSeconds,
} = webhookModule;
const { WEBHOOK_MEDIA_QUEUE_WAIT_MS } = await import('./inboundMediaSecurity.js');

const TOKEN = '11111111-1111-4111-8111-111111111111';

function channel(overrides: Partial<Channel> = {}): Channel {
  const provider = (overrides.provider ?? 'africas_talking') as WhatsAppProvider;
  return {
    id: 'ch-1',
    name: 'Support',
    phone_number: '+27820000000',
    kind: 'whatsapp',
    provider,
    capabilities: getWhatsAppCapabilities(provider),
    status: 'connected',
    created_at: '2026-01-01T00:00:00Z',
    last_connected_at: null,
    inbound_token: TOKEN,
    ...overrides,
  };
}

type Calls = {
  processed: NormalizedWhatsAppInbound[];
  duplicateChecks: string[];
  downloads: string[];
};

function makeDeps(
  overrides: Partial<AfricasTalkingWebhookDeps> = {},
): { deps: AfricasTalkingWebhookDeps; calls: Calls } {
  const calls: Calls = { processed: [], duplicateChecks: [], downloads: [] };
  const deps: AfricasTalkingWebhookDeps = {
    findChannel: async () => channel(),
    isDuplicate: async waId => {
      calls.duplicateChecks.push(waId);
      return false;
    },
    processInbound: async input => {
      calls.processed.push(input);
      return { ok: true, ticketId: 't-1', messageId: 'm-1' };
    },
    downloadMedia: async url => {
      calls.downloads.push(url);
      return { base64: 'QUJD', mime: 'image/png' };
    },
    ...overrides,
  };
  return { deps, calls };
}

describe('africasTalkingWebhook module surface', () => {
  it('still re-exports the media guard so existing import sites keep working', async () => {
    const media = await import('./inboundMediaSecurity.js');
    assert.equal(webhookModule.downloadInboundMedia, media.downloadInboundMedia);
    assert.equal(webhookModule.downloadWebhookMedia, media.downloadWebhookMedia);
    assert.equal(webhookModule.WEBHOOK_MEDIA_MAX_BYTES, media.WEBHOOK_MEDIA_MAX_BYTES);
    assert.equal(webhookModule.WEBHOOK_MEDIA_CONCURRENCY, media.WEBHOOK_MEDIA_CONCURRENCY);
    assert.equal(webhookModule.WEBHOOK_MEDIA_QUEUE_LIMIT, media.WEBHOOK_MEDIA_QUEUE_LIMIT);

    const errors = await import('./africasTalkingWebhookError.js');
    assert.equal(webhookModule.AfricasTalkingWebhookError, errors.AfricasTalkingWebhookError);
  });
});

describe('webhookRetryAfterSeconds', () => {
  it('asks a shed caller to retry after the queue-wait deadline', () => {
    const seconds = webhookRetryAfterSeconds({
      error: 'Inbound media fetch is busy, retry shortly',
      reason: 'media_busy',
    });
    assert.equal(seconds, 5);
    assert.equal(
      seconds! * 1000, WEBHOOK_MEDIA_QUEUE_WAIT_MS,
      'the hint must track the deadline a waiter is actually shed at',
    );
  });

  it('adds no retry hint to any other response', () => {
    const others: unknown[] = [
      { ok: true, ticketId: 't-1' },
      { ok: true, duplicate: true },
      { error: 'x', reason: 'media_rejected' },
      { error: 'x', reason: 'media_unavailable' },
      { error: 'x', reason: 'invalid_payload' },
      { error: 'Unknown token' },
      { reason: 'MEDIA_BUSY' },
      null, undefined, 'media_busy', 42, [], { reason: 42 },
    ];
    for (const body of others) {
      assert.equal(webhookRetryAfterSeconds(body), null, JSON.stringify(body) ?? String(body));
    }
  });
});

describe('mapAfricasTalkingWebhook', () => {
  it('maps the documented text callback', () => {
    const mapped = mapAfricasTalkingWebhook({
      waNumber: '+27820000000',
      phoneNumber: '+27821111111',
      messageId: 'at-in-1',
      type: 'text',
      text: 'Need help',
    });
    assert.deepEqual(mapped, {
      phone: '+27821111111',
      waId: 'at-in-1',
      text: 'Need help',
      messageType: 'text',
    });
  });

  it('accepts the from / mobile / id aliases and a nested body.message', () => {
    assert.deepEqual(
      mapAfricasTalkingWebhook({ from: '+27821111111', id: 'at-in-2', body: { message: 'Hi there' } }),
      { phone: '+27821111111', waId: 'at-in-2', text: 'Hi there', messageType: 'text' },
    );
    assert.deepEqual(
      mapAfricasTalkingWebhook({ mobile: '+27821111111', messageId: 'at-in-3', message: 'Hello' }),
      { phone: '+27821111111', waId: 'at-in-3', text: 'Hello', messageType: 'text' },
    );
  });

  it('maps image, voice and document callbacks with the media URL and caption', () => {
    assert.deepEqual(
      mapAfricasTalkingWebhook({
        phoneNumber: '+27821111111',
        messageId: 'at-in-4',
        type: 'image',
        mediaUrl: 'https://files.africastalking.com/a.png',
        caption: 'Screenshot',
      }),
      {
        phone: '+27821111111',
        waId: 'at-in-4',
        text: 'Screenshot',
        messageType: 'image',
        mediaUrl: 'https://files.africastalking.com/a.png',
      },
    );

    assert.deepEqual(
      mapAfricasTalkingWebhook({
        phoneNumber: '+27821111111',
        messageId: 'at-in-5',
        body: { mediaType: 'Voice', url: 'https://files.africastalking.com/a.ogg' },
      }),
      {
        phone: '+27821111111',
        waId: 'at-in-5',
        messageType: 'voice_note',
        mediaUrl: 'https://files.africastalking.com/a.ogg',
      },
    );

    assert.deepEqual(
      mapAfricasTalkingWebhook({
        phoneNumber: '+27821111111',
        messageId: 'at-in-6',
        type: 'Document',
        url: 'https://files.africastalking.com/a.pdf',
        fileName: 'invoice.pdf',
      }),
      {
        phone: '+27821111111',
        waId: 'at-in-6',
        messageType: 'document',
        mediaUrl: 'https://files.africastalking.com/a.pdf',
        mediaFileName: 'invoice.pdf',
      },
    );
  });

  it('rejects unknown events and payloads it cannot classify', () => {
    const cases: unknown[] = [
      { phoneNumber: '+27821111111', messageId: 'x', type: 'delivery', status: 'Delivered' },
      { phoneNumber: '+27821111111', messageId: 'x', type: 'video', mediaUrl: 'https://f/a.mp4' },
      { phoneNumber: '+27821111111', messageId: 'x' },
      { phoneNumber: '+27821111111', type: 'text', text: 'no id' },
      { messageId: 'x', type: 'text', text: 'no sender' },
      { phoneNumber: '+27821111111', messageId: 'x', type: 'text', text: '   ' },
      'not-an-object',
      null,
      [],
    ];
    for (const payload of cases) {
      assert.throws(
        () => mapAfricasTalkingWebhook(payload),
        (err: unknown) => err instanceof AfricasTalkingWebhookError && err.statusCode === 400,
        JSON.stringify(payload),
      );
    }
  });

  it('rejects conflicting aliases instead of guessing', () => {
    assert.throws(
      () => mapAfricasTalkingWebhook({
        phoneNumber: '+27821111111',
        from: '+27829999999',
        messageId: 'at-in-7',
        text: 'Hi',
      }),
      (err: unknown) => err instanceof AfricasTalkingWebhookError && err.reason === 'ambiguous_payload',
    );
    assert.throws(
      () => mapAfricasTalkingWebhook({
        phoneNumber: '+27821111111',
        messageId: 'at-in-8',
        text: 'Hi',
        body: { message: 'Different' },
      }),
      (err: unknown) => err instanceof AfricasTalkingWebhookError && err.reason === 'ambiguous_payload',
    );
  });

  it('never echoes payload content in rejection messages', () => {
    try {
      mapAfricasTalkingWebhook({ phoneNumber: '+27821111111', messageId: 'x', type: 'delivery' });
      assert.fail('expected rejection');
    } catch (err) {
      assert.ok(err instanceof AfricasTalkingWebhookError);
      assert.ok(!err.message.includes('delivery'));
      assert.ok(!err.message.includes('27821111111'));
    }
  });

  it('reads the channel number only from waNumber', () => {
    assert.equal(readAfricasTalkingWaNumber({ waNumber: ' +27820000000 ' }), '+27820000000');
    assert.equal(readAfricasTalkingWaNumber({ phoneNumber: '+27821111111' }), null);
    assert.equal(readAfricasTalkingWaNumber({ waNumber: 42 }), null);
  });
});

describe('handleAfricasTalkingWebhook', () => {
  const textBody = { phoneNumber: '+27821111111', messageId: 'at-in-1', type: 'text', text: 'Need help' };

  it('processes a text callback for the resolved channel', async () => {
    const { deps, calls } = makeDeps();
    const res = await handleAfricasTalkingWebhook(TOKEN, textBody, deps);

    assert.deepEqual(res, { status: 200, body: { ok: true, ticketId: 't-1', messageId: 'm-1' } });
    assert.deepEqual(calls.processed, [
      { channelId: 'ch-1', phone: '+27821111111', waId: 'at-in-1', text: 'Need help', messageType: 'text' },
    ]);
    assert.deepEqual(calls.downloads, []);
  });

  it('returns 404 for an unknown, malformed or non-Africa\'s Talking token', async () => {
    for (const found of [null, channel({ provider: 'baileys' }), channel({ kind: 'email', provider: null })]) {
      const { deps, calls } = makeDeps({ findChannel: async () => found });
      const res = await handleAfricasTalkingWebhook(TOKEN, textBody, deps);
      assert.equal(res.status, 404);
      assert.deepEqual(res.body, { error: 'Unknown token' });
      assert.deepEqual(calls.processed, []);
    }

    const { deps, calls } = makeDeps({
      findChannel: async () => {
        throw new Error('findChannel must not run for a malformed token');
      },
    });
    const res = await handleAfricasTalkingWebhook('not-a-uuid', textBody, deps);
    assert.equal(res.status, 404);
    assert.deepEqual(calls.processed, []);
  });

  it('rejects a callback addressed to another channel number', async () => {
    const { deps, calls } = makeDeps();
    const res = await handleAfricasTalkingWebhook(TOKEN, { ...textBody, waNumber: '+27829999999' }, deps);

    assert.equal(res.status, 400);
    assert.deepEqual(calls.processed, []);
    assert.ok(!JSON.stringify(res.body).includes('27829999999'));
  });

  it('accepts a matching waNumber in any format', async () => {
    const { deps, calls } = makeDeps();
    const res = await handleAfricasTalkingWebhook(TOKEN, { ...textBody, waNumber: '27 82 000 0000' }, deps);

    assert.equal(res.status, 200);
    assert.equal(calls.processed.length, 1);
  });

  it('answers duplicate message ids with 200 and no processing or download', async () => {
    const { deps, calls } = makeDeps({ isDuplicate: async () => true });
    const res = await handleAfricasTalkingWebhook(
      TOKEN,
      { phoneNumber: '+27821111111', messageId: 'at-in-1', type: 'image', mediaUrl: 'https://f.example.com/a.png' },
      deps,
    );

    assert.deepEqual(res, { status: 200, body: { ok: true, duplicate: true } });
    assert.deepEqual(calls.processed, []);
    assert.deepEqual(calls.downloads, []);
  });

  it('downloads supported media and forwards the bytes to the processor', async () => {
    const { deps, calls } = makeDeps();
    const res = await handleAfricasTalkingWebhook(
      TOKEN,
      {
        phoneNumber: '+27821111111',
        messageId: 'at-in-4',
        type: 'image',
        mediaUrl: 'https://files.example.com/a.png',
        caption: 'Screenshot',
      },
      deps,
    );

    assert.equal(res.status, 200);
    assert.deepEqual(calls.downloads, ['https://files.example.com/a.png']);
    assert.deepEqual(calls.processed, [
      {
        channelId: 'ch-1',
        phone: '+27821111111',
        waId: 'at-in-4',
        text: 'Screenshot',
        messageType: 'image',
        mediaBase64: 'QUJD',
        mediaMime: 'image/png',
      },
    ]);
  });

  it('reports a failed media fetch as a retryable gateway error', async () => {
    const { deps, calls } = makeDeps({
      downloadMedia: async () => {
        throw new AfricasTalkingWebhookError('media_unavailable', 502, 'Inbound media could not be downloaded');
      },
    });
    const res = await handleAfricasTalkingWebhook(
      TOKEN,
      { phoneNumber: '+27821111111', messageId: 'at-in-4', type: 'image', mediaUrl: 'https://files.example.com/a.png' },
      deps,
    );

    assert.equal(res.status, 502);
    assert.deepEqual(calls.processed, []);
  });

  it('reports a shed media fetch as a retryable 503 so the provider redelivers', async () => {
    const { deps, calls } = makeDeps({
      downloadMedia: async () => {
        throw new AfricasTalkingWebhookError('media_busy', 503, 'Inbound media fetch is busy, retry shortly');
      },
    });
    const res = await handleAfricasTalkingWebhook(
      TOKEN,
      { phoneNumber: '+27821111111', messageId: 'at-in-9', type: 'image', mediaUrl: 'https://files.example.com/a.png' },
      deps,
    );

    assert.equal(res.status, 503);
    assert.deepEqual(res.body, { error: 'Inbound media fetch is busy, retry shortly', reason: 'media_busy' });
    assert.deepEqual(calls.processed, []);
  });

  it('passes the ignored group result straight through', async () => {
    const { deps } = makeDeps({
      processInbound: async () => ({ ok: true, ignored: true, reason: 'no_group_ticket' }),
    });
    const res = await handleAfricasTalkingWebhook(TOKEN, textBody, deps);
    assert.deepEqual(res, { status: 200, body: { ok: true, ignored: true, reason: 'no_group_ticket' } });
  });
});

/**
 * Delivery receipts, status updates and read receipts are normal traffic on the same callback URL.
 * Answering them with 400 makes the provider treat healthy delivery as a failing endpoint and retry
 * forever, so they are acknowledged and dropped instead.
 */
const STATUS_EVENTS: Array<[label: string, body: Record<string, unknown>]> = [
  ['delivery status', { status: 'Delivered', messageId: 'at-evt-1', phoneNumber: '+27831234567' }],
  ['sent status', { status: 'Sent', messageId: 'at-evt-2', phoneNumber: '+27831234567' }],
  ['read receipt', { status: 'Read', messageId: 'at-evt-3', phoneNumber: '+27831234567' }],
  ['failed status', { status: 'Failed', messageId: 'at-evt-4', phoneNumber: '+27831234567' }],
  ['typed delivery event', { type: 'delivery', messageId: 'at-evt-5', phoneNumber: '+27831234567' }],
  ['typed read event', { type: 'read', messageId: 'at-evt-6', phoneNumber: '+27831234567' }],
  ['typed status event', { type: 'status', messageId: 'at-evt-7', phoneNumber: '+27831234567' }],
  ['named message status event', { eventType: 'messageStatus', messageId: 'at-evt-8' }],
  ['named delivery event', { event: 'delivery_status', messageId: 'at-evt-9' }],
  ['nested delivery status', { body: { type: 'delivery' }, messageId: 'at-evt-10', phoneNumber: '+27831234567' }],
  ['status with no other usable field', { status: 'delivered' }],
  ['delivery named in messageType', { messageType: 'delivery', messageId: 'at-evt-11', phoneNumber: '+27831234567' }],
  ['read receipt named in messageType', { messageType: 'read', messageId: 'at-evt-12', phoneNumber: '+27831234567' }],
  ['status named in mediaType', { mediaType: 'status', messageId: 'at-evt-13', phoneNumber: '+27831234567' }],
  ['nested messageType delivery', { body: { messageType: 'delivered' }, messageId: 'at-evt-14', phoneNumber: '+27831234567' }],
  ['delivery event echoing text type', { eventType: 'delivery', messageType: 'text', messageId: 'at-evt-15', phoneNumber: '+27831234567' }],
  ['delivered status echoing text type', { type: 'text', status: 'delivered', messageId: 'at-evt-16', phoneNumber: '+27831234567' }],
];

/** Real inbound media, described through the same alias fields — must never be swallowed. */
const REAL_MEDIA_MESSAGES: Array<[label: string, body: Record<string, unknown>]> = [
  ['image in messageType', { phoneNumber: '+27831234567', messageId: 'at-m-1', messageType: 'image', mediaUrl: 'https://files.example.com/a.png' }],
  ['audio in messageType', { phoneNumber: '+27831234567', messageId: 'at-m-2', messageType: 'audio', mediaUrl: 'https://files.example.com/a.ogg' }],
  ['voice in mediaType', { phoneNumber: '+27831234567', messageId: 'at-m-3', mediaType: 'voice', mediaUrl: 'https://files.example.com/b.ogg' }],
  ['document in mediaType', { phoneNumber: '+27831234567', messageId: 'at-m-4', mediaType: 'document', mediaUrl: 'https://files.example.com/c.pdf' }],
  ['text in messageType', { phoneNumber: '+27831234567', messageId: 'at-m-5', messageType: 'text', text: 'hello' }],
  ['nested mediaType image', { phoneNumber: '+27831234567', messageId: 'at-m-6', body: { mediaType: 'image', url: 'https://files.example.com/d.png' } }],
];

describe('classifyAfricasTalkingCallback', () => {
  for (const [label, body] of STATUS_EVENTS) {
    it(`classifies a ${label} as an ignorable event`, () => {
      assert.deepEqual(classifyAfricasTalkingCallback(body), { kind: 'ignored', reason: 'unsupported_event' });
    });
  }

  it('classifies a real text message as a message, with the mapped fields', () => {
    const result = classifyAfricasTalkingCallback({
      phoneNumber: '+27831234567',
      messageId: 'at-1',
      text: 'hello',
    });

    assert.equal(result.kind, 'message');
    assert.deepEqual(result.kind === 'message' ? result.message : null, {
      phone: '+27831234567',
      waId: 'at-1',
      text: 'hello',
      messageType: 'text',
    });
  });

  for (const [label, body] of REAL_MEDIA_MESSAGES) {
    it(`does not swallow a real message with ${label}`, () => {
      const result = classifyAfricasTalkingCallback(body);
      assert.equal(result.kind, 'message', `${label} must still be stored`);
    });
  }

  it('lets a real message type win over an event-shaped sibling field', () => {
    // A known message type anywhere in the type aliases is decisive: never drop actual content.
    const result = classifyAfricasTalkingCallback({
      phoneNumber: '+27831234567',
      messageId: 'at-m-7',
      messageType: 'image',
      mediaUrl: 'https://files.example.com/e.png',
      status: 'delivered',
    });
    assert.equal(result.kind, 'message');
  });

  it('classifies a media message as a message', () => {
    const result = classifyAfricasTalkingCallback({
      phoneNumber: '+27831234567',
      messageId: 'at-2',
      type: 'image',
      mediaUrl: 'https://files.example.com/a.png',
    });
    assert.equal(result.kind, 'message');
  });

  it('does not swallow a message that merely carries a delivery-ish status field', () => {
    // A real inbound message with `status` alongside content is still a message.
    const result = classifyAfricasTalkingCallback({
      phoneNumber: '+27831234567',
      messageId: 'at-3',
      status: 'Delivered',
      text: 'hello',
    });
    assert.equal(result.kind, 'message');
  });

  it('still throws for genuinely malformed payloads rather than ignoring them', () => {
    const malformed: Array<[string, unknown]> = [
      ['not an object', 'delivered'],
      ['array', [{ status: 'Delivered' }]],
      ['null', null],
      ['empty object', {}],
      ['no phone', { messageId: 'at-4', text: 'hello' }],
      ['no message id', { phoneNumber: '+27831234567', text: 'hello' }],
      ['unusable phone', { phoneNumber: 'not-a-number', messageId: 'at-5', text: 'hello' }],
      ['unknown message type', { phoneNumber: '+27831234567', messageId: 'at-6', type: 'sticker' }],
    ];

    for (const [label, body] of malformed) {
      assert.throws(
        () => classifyAfricasTalkingCallback(body),
        (err: unknown) => err instanceof AfricasTalkingWebhookError && err.statusCode === 400,
        `${label} must stay a 400`,
      );
    }
  });
});

describe('handleAfricasTalkingWebhook — non-message events', () => {
  for (const [label, body] of STATUS_EVENTS) {
    it(`acknowledges a ${label} with 200 and stores nothing`, async () => {
      const { deps, calls } = makeDeps();
      const res = await handleAfricasTalkingWebhook(TOKEN, body, deps);

      assert.deepEqual(res, {
        status: 200,
        body: { ok: true, ignored: true, reason: 'unsupported_event' },
      });
      assert.deepEqual(calls.processed, [], 'no ticket or message may be created');
      assert.deepEqual(calls.downloads, [], 'no media may be fetched');
      assert.deepEqual(calls.duplicateChecks, [], 'a status event has no inbound id to dedupe');
    });
  }

  it('still rejects a malformed payload with 400', async () => {
    const { deps, calls } = makeDeps();
    const res = await handleAfricasTalkingWebhook(TOKEN, { phoneNumber: '+27831234567' }, deps);

    assert.equal(res.status, 400);
    assert.deepEqual(calls.processed, []);
  });
});
