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 { BaileysWhatsAppProvider } from './baileysWhatsAppProvider.js';
import { AfricasTalkingWhatsAppProvider, WhatsAppProviderError } from './africasTalkingWhatsAppProvider.js';
import { requireWhatsAppProviderMethod, resolveWhatsAppProvider } from './whatsappProvider.js';

type RecordedRequest = { url: string; init: RequestInit & { headers: Record<string, string> } };

function makeFetch(respond: () => Response | Promise<Response>) {
  const requests: RecordedRequest[] = [];
  const fetchImpl = (async (url: string | URL | Request, init?: RequestInit) => {
    requests.push({ url: String(url), init: init as RecordedRequest['init'] });
    return respond();
  }) as typeof fetch;
  return { fetchImpl, requests };
}

const json = (body: unknown, status = 200) =>
  new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });

function channel(provider: WhatsAppProvider): Channel {
  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,
  };
}

const AT_URL = 'https://chat.africastalking.com/whatsapp/message/send';
const atCreds = { username: 'account', apiKey: 'secret' };

describe("Africa's Talking adapter", () => {
  it('posts direct text to the official endpoint and returns the message id', async () => {
    const { fetchImpl, requests } = makeFetch(() => json({ status: 'SENT', messageId: 'at-1' }));
    const adapter = new AfricasTalkingWhatsAppProvider(atCreds, fetchImpl);

    const id = await adapter.sendText(channel('africas_talking'), '+27821111111', 'Hello');

    assert.equal(id, 'at-1');
    assert.equal(requests.length, 1);
    const [request] = requests;
    assert.equal(request.url, AT_URL);
    assert.equal(request.init.method, 'POST');
    assert.equal(request.init.headers.apiKey, 'secret');
    assert.equal(request.init.headers['Content-Type'], 'application/json');
    assert.ok(request.init.signal);
    assert.deepEqual(JSON.parse(String(request.init.body)), {
      username: 'account',
      waNumber: '+27820000000',
      phoneNumber: '+27821111111',
      body: { message: 'Hello' },
    });
  });

  it('maps media kinds to Africa\'s Talking media types with the hosted URL', async () => {
    const cases = [
      ['image', 'Image'],
      ['voice_note', 'Voice'],
      ['document', 'Document'],
    ] as const;
    for (const [kind, mediaType] of cases) {
      const { fetchImpl, requests } = makeFetch(() => json({ status: 'SENT', messageId: `at-${kind}` }));
      const adapter = new AfricasTalkingWhatsAppProvider(atCreds, fetchImpl);
      const id = await adapter.sendMedia(channel('africas_talking'), '+27821111111', {
        kind,
        mediaBase64: 'QUJD',
        mediaUrl: 'https://files.example.com/signed/x',
        mediaMime: 'application/octet-stream',
        caption: 'Cap',
        fileName: 'a.pdf',
      });
      assert.equal(id, `at-${kind}`);
      assert.deepEqual(JSON.parse(String(requests[0].init.body)), {
        username: 'account',
        waNumber: '+27820000000',
        phoneNumber: '+27821111111',
        body: { mediaType, url: 'https://files.example.com/signed/x', caption: 'Cap' },
      });
    }
  });

  it('fails clearly without a network call when media has no hosted URL', async () => {
    const { fetchImpl, requests } = makeFetch(() => json({ status: 'SENT', messageId: 'x' }));
    const adapter = new AfricasTalkingWhatsAppProvider(atCreds, fetchImpl);
    await assert.rejects(
      adapter.sendMedia(channel('africas_talking'), '+27821111111', {
        kind: 'image', mediaBase64: 'QUJD', mediaUrl: null, mediaMime: 'image/png',
      }),
      (err: unknown) => err instanceof WhatsAppProviderError && err.type === 'media_url_required' && !/secret/.test(err.message),
    );
    assert.equal(requests.length, 0);
  });

  it('throws redacted errors for authentication and HTTP failures', async () => {
    for (const status of [401, 403, 500]) {
      const { fetchImpl } = makeFetch(() =>
        new Response('{"error":"bad apiKey secret for account"}', { status }));
      const adapter = new AfricasTalkingWhatsAppProvider(atCreds, fetchImpl);
      await assert.rejects(
        adapter.sendText(channel('africas_talking'), '+27821111111', 'Hello'),
        (err: unknown) => {
          assert.ok(err instanceof WhatsAppProviderError);
          assert.equal(err.type, status === 500 ? 'http' : 'authentication');
          assert.equal(err.httpStatus, status);
          assert.ok(err.message.includes(String(status)));
          assert.ok(!err.message.includes('secret'));
          assert.ok(!err.message.includes('bad apiKey'));
          assert.ok(!JSON.stringify(err).includes('secret'));
          return true;
        },
      );
    }
  });

  it('redacts network failures that echo the API key and reports timeouts', async () => {
    const leaky = new AfricasTalkingWhatsAppProvider(atCreds, (async () => {
      throw new Error('connect failed apiKey=secret');
    }) as typeof fetch);
    await assert.rejects(
      leaky.sendText(channel('africas_talking'), '+27821111111', 'Hello'),
      (err: unknown) => err instanceof WhatsAppProviderError && err.type === 'network' && !err.message.includes('secret'),
    );

    const timeout = new AfricasTalkingWhatsAppProvider(atCreds, (async () => {
      throw Object.assign(new Error('The operation was aborted due to timeout secret'), { name: 'TimeoutError' });
    }) as typeof fetch);
    await assert.rejects(
      timeout.sendText(channel('africas_talking'), '+27821111111', 'Hello'),
      (err: unknown) => err instanceof WhatsAppProviderError && err.type === 'timeout' && !err.message.includes('secret'),
    );
  });

  it('does not report a non-SENT provider response as success', async () => {
    const { fetchImpl } = makeFetch(() => json({ status: 'FAILED', messageId: 'at-9', error: 'secret detail' }));
    const adapter = new AfricasTalkingWhatsAppProvider(atCreds, fetchImpl);
    await assert.rejects(
      adapter.sendText(channel('africas_talking'), '+27821111111', 'Hello'),
      (err: unknown) => err instanceof WhatsAppProviderError && err.type === 'rejected'
        && !err.message.includes('secret') && !err.message.includes('FAILED'),
    );
  });

  it('reports malformed success bodies without leaking them', async () => {
    const { fetchImpl } = makeFetch(() => new Response('<html>secret</html>', { status: 200 }));
    const adapter = new AfricasTalkingWhatsAppProvider(atCreds, fetchImpl);
    await assert.rejects(
      adapter.sendText(channel('africas_talking'), '+27821111111', 'Hello'),
      (err: unknown) => err instanceof WhatsAppProviderError && err.type === 'invalid_response' && !err.message.includes('secret'),
    );
  });

  it('rejects group and session operations before any network call', async () => {
    const { fetchImpl, requests } = makeFetch(() => json({}));
    const adapter = new AfricasTalkingWhatsAppProvider(atCreds, fetchImpl);
    const ch = channel('africas_talking');

    for (const method of ['startSession', 'terminateSession', 'createGroup', 'getGroupMetadata', 'updateGroupParticipants'] as const) {
      assert.equal(adapter[method], undefined, method);
      assert.throws(
        () => requireWhatsAppProviderMethod(ch, adapter, method),
        (err: unknown) => (err as { statusCode?: number }).statusCode === 400
          && /africas_talking/.test((err as Error).message),
      );
    }
    assert.equal(requests.length, 0);
  });
});

describe('Baileys adapter', () => {
  const worker = 'http://worker:3002';

  it('keeps the current text payload and URL', async () => {
    const { fetchImpl, requests } = makeFetch(() => json({ ok: true, waMessageId: 'wa-1' }));
    const adapter = new BaileysWhatsAppProvider(fetchImpl, worker);
    assert.equal(await adapter.sendText(channel('baileys'), '2782@s.whatsapp.net', 'Hi'), 'wa-1');
    assert.equal(requests[0].url, `${worker}/send`);
    assert.equal(requests[0].init.method, 'POST');
    assert.deepEqual(requests[0].init.headers, { 'Content-Type': 'application/json' });
    assert.deepEqual(JSON.parse(String(requests[0].init.body)), {
      channelId: 'ch-1', phone: '2782@s.whatsapp.net', text: 'Hi', kind: 'text',
    });
  });

  it('keeps the current media payload (base64, no hosted URL) and null id fallback', async () => {
    const { fetchImpl, requests } = makeFetch(() => json({ ok: true, waMessageId: null }));
    const adapter = new BaileysWhatsAppProvider(fetchImpl, worker);
    const id = await adapter.sendMedia(channel('baileys'), '2782', {
      kind: 'document', mediaBase64: 'QUJD', mediaUrl: 'https://files.example.com/x', mediaMime: 'application/pdf',
      caption: undefined, fileName: 'a.pdf',
    });
    assert.equal(id, null);
    assert.equal(requests[0].url, `${worker}/send`);
    assert.deepEqual(JSON.parse(String(requests[0].init.body)), {
      channelId: 'ch-1', phone: '2782', kind: 'document', mediaBase64: 'QUJD',
      mediaMime: 'application/pdf', fileName: 'a.pdf',
    });
  });

  it('keeps the current session, group, and participant worker calls', async () => {
    const { fetchImpl, requests } = makeFetch(() =>
      json({ ok: true, groupJid: '1@g.us', subject: 'S', phones: ['1'], participantCount: 1 }));
    const adapter = new BaileysWhatsAppProvider(fetchImpl, worker);
    const ch = channel('baileys');

    await adapter.startSession!(ch, true);
    await adapter.terminateSession!(ch);
    assert.deepEqual(await adapter.createGroup!(ch, 'S', ['1']), { groupJid: '1@g.us', subject: 'S' });
    assert.equal((await adapter.getGroupMetadata!(ch, '1@g.us')).participantCount, 1);
    await adapter.updateGroupParticipants!(ch, '1@g.us', 'add', ['2']);

    assert.deepEqual(requests.map(r => [r.init.method, r.url]), [
      ['POST', `${worker}/sessions`],
      ['DELETE', `${worker}/sessions/ch-1`],
      ['POST', `${worker}/group/create`],
      ['POST', `${worker}/group/metadata`],
      ['POST', `${worker}/group/participants`],
    ]);
    assert.deepEqual(JSON.parse(String(requests[0].init.body)), { channelId: 'ch-1', clearAuth: true });
    assert.deepEqual(JSON.parse(String(requests[2].init.body)), { channelId: 'ch-1', subject: 'S', phones: ['1'] });
    assert.deepEqual(JSON.parse(String(requests[3].init.body)), { channelId: 'ch-1', groupJid: '1@g.us' });
    assert.deepEqual(JSON.parse(String(requests[4].init.body)), {
      channelId: 'ch-1', groupJid: '1@g.us', action: 'add', phones: ['2'],
    });
  });

  it('keeps worker error messages', async () => {
    const { fetchImpl } = makeFetch(() => new Response('boom', { status: 502 }));
    const adapter = new BaileysWhatsAppProvider(fetchImpl, worker);
    await assert.rejects(adapter.sendText(channel('baileys'), '1', 'x'), /Worker send failed for ch-1: 502 boom/);
    await assert.rejects(adapter.startSession!(channel('baileys'), false), /Worker rejected session start for ch-1: 502 boom/);
  });
});

describe('resolveWhatsAppProvider', () => {
  it('returns the adapter for the channel provider', () => {
    const baileys = new BaileysWhatsAppProvider();
    const at = new AfricasTalkingWhatsAppProvider(atCreds);
    const registry = { baileys: () => baileys, africas_talking: () => at };
    assert.equal(resolveWhatsAppProvider(channel('baileys'), registry), baileys);
    assert.equal(resolveWhatsAppProvider(channel('africas_talking'), registry), at);
  });

  it('rejects channels without a WhatsApp provider', () => {
    const ch = { ...channel('baileys'), provider: null };
    assert.throws(() => resolveWhatsAppProvider(ch), (err: unknown) => (err as { statusCode?: number }).statusCode === 400);
  });

  it('does not resolve inherited object properties as providers', () => {
    for (const provider of ['toString', 'constructor', '__proto__', 'hasOwnProperty']) {
      const ch = { ...channel('baileys'), provider: provider as WhatsAppProvider };
      assert.throws(() => resolveWhatsAppProvider(ch), (err: unknown) => (err as { statusCode?: number }).statusCode === 400, provider);
    }
  });

  it("fails with a config error when Africa's Talking secrets are missing", () => {
    const prev = { u: process.env.AFRICASTALKING_USERNAME, k: process.env.AFRICASTALKING_API_KEY };
    delete process.env.AFRICASTALKING_USERNAME;
    delete process.env.AFRICASTALKING_API_KEY;
    try {
      assert.throws(
        () => resolveWhatsAppProvider(channel('africas_talking')),
        (err: unknown) => err instanceof WhatsAppProviderError && err.type === 'config' && (err as { statusCode?: number }).statusCode === 503,
      );
    } finally {
      if (prev.u !== undefined) process.env.AFRICASTALKING_USERNAME = prev.u;
      if (prev.k !== undefined) process.env.AFRICASTALKING_API_KEY = prev.k;
    }
  });
});
