import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import type { Channel } from '@wa-ticketing/shared';
import { WhatsAppProviderError } from './africasTalkingWhatsAppProvider.js';
import { canStaffSendOnChannel } from './channelProviderRules.js';
import { trackOutboundProviderStatus } from './providerStatusLifecycle.js';

const channel = (over: Partial<Channel> = {}): Channel => ({
  id: 'ch-1',
  name: 'Support',
  phone_number: '+254700000001',
  kind: 'whatsapp',
  provider: 'africas_talking',
  status: 'connected',
  created_at: '2026-01-01T00:00:00.000Z',
  last_connected_at: null,
  ...over,
});

/** Records every status write so a test can assert on order as well as count. */
function statusRecorder() {
  const writes: Array<{ id: string; status: Channel['status'] }> = [];
  return {
    writes,
    setStatus: async (id: string, status: Channel['status']) => {
      writes.push({ id, status });
    },
  };
}

const authError = () =>
  new WhatsAppProviderError('africas_talking', 'authentication', "Africa's Talking authentication failed (HTTP 401)", 401);

describe('trackOutboundProviderStatus', () => {
  it('marks the channel disconnected when the provider rejects our credentials', async () => {
    const rec = statusRecorder();
    const thrown = authError();

    await assert.rejects(
      () => trackOutboundProviderStatus(channel(), rec.setStatus, () => Promise.reject(thrown)),
      (err: unknown) => err === thrown,
    );

    assert.deepEqual(rec.writes, [{ id: 'ch-1', status: 'disconnected' }]);
  });

  it('propagates the original provider error untouched', async () => {
    const rec = statusRecorder();
    const thrown = authError();

    const caught = await trackOutboundProviderStatus(channel(), rec.setStatus, () => Promise.reject(thrown))
      .then(() => null, (err: unknown) => err);

    // Identity, not shape: the caller's error mapping keys off type/statusCode.
    assert.equal(caught, thrown);
    assert.ok(caught instanceof WhatsAppProviderError);
    assert.equal((caught as WhatsAppProviderError).type, 'authentication');
    assert.equal((caught as WhatsAppProviderError).statusCode, 502);
  });

  it('restores connected when a disconnected channel sends successfully again', async () => {
    const rec = statusRecorder();

    const result = await trackOutboundProviderStatus(
      channel({ status: 'disconnected' }),
      rec.setStatus,
      () => Promise.resolve('wamid-1'),
    );

    assert.equal(result, 'wamid-1');
    assert.deepEqual(rec.writes, [{ id: 'ch-1', status: 'connected' }]);
  });

  it('writes nothing when a already-connected channel sends successfully', async () => {
    const rec = statusRecorder();

    const result = await trackOutboundProviderStatus(channel(), rec.setStatus, () => Promise.resolve('wamid-2'));

    assert.equal(result, 'wamid-2');
    assert.deepEqual(rec.writes, []);
  });

  it('leaves status alone for non-authentication provider failures', async () => {
    // Every other failure type is transient or caller error; none of them means our creds are dead.
    const types = ['http', 'timeout', 'network', 'rejected', 'invalid_response', 'media_url_required', 'config'] as const;

    for (const type of types) {
      const rec = statusRecorder();
      const thrown = new WhatsAppProviderError('africas_talking', type, `failed: ${type}`);

      await assert.rejects(
        () => trackOutboundProviderStatus(channel(), rec.setStatus, () => Promise.reject(thrown)),
        (err: unknown) => err === thrown,
      );

      assert.deepEqual(rec.writes, [], `${type} must not change channel status`);
    }
  });

  it('leaves status alone for errors that are not provider errors at all', async () => {
    const rec = statusRecorder();
    const thrown = new Error('socket hang up');

    await assert.rejects(
      () => trackOutboundProviderStatus(channel(), rec.setStatus, () => Promise.reject(thrown)),
      (err: unknown) => err === thrown,
    );

    assert.deepEqual(rec.writes, []);
  });

  it('never touches the status of a Baileys channel — the worker owns that', async () => {
    const baileys = channel({ provider: 'baileys', status: 'disconnected' });

    const onSuccess = statusRecorder();
    await trackOutboundProviderStatus(baileys, onSuccess.setStatus, () => Promise.resolve('ok'));
    assert.deepEqual(onSuccess.writes, []);

    const onAuthFailure = statusRecorder();
    const thrown = authError();
    await assert.rejects(
      () => trackOutboundProviderStatus(baileys, onAuthFailure.setStatus, () => Promise.reject(thrown)),
      (err: unknown) => err === thrown,
    );
    assert.deepEqual(onAuthFailure.writes, []);
  });

  it('lets the provider error win when the status write itself fails', async () => {
    const thrown = authError();
    const failingWrite = async () => {
      throw new Error('db unavailable');
    };

    const caught = await trackOutboundProviderStatus(channel(), failingWrite, () => Promise.reject(thrown))
      .then(() => null, (err: unknown) => err);

    // Bookkeeping must never mask why the send actually failed.
    assert.equal(caught, thrown);
  });

  it('still returns a successful send when the recovery write fails', async () => {
    const failingWrite = async () => {
      throw new Error('db unavailable');
    };

    const result = await trackOutboundProviderStatus(
      channel({ status: 'disconnected' }),
      failingWrite,
      () => Promise.resolve('wamid-3'),
    );

    assert.equal(result, 'wamid-3');
  });
});

/**
 * The two halves of the Africa's Talking recovery path have to agree, or the channel is stranded:
 * the guard must let a disconnected channel through, and the wrapper must promote it on success.
 */
describe('Africa\'s Talking credential revalidation through a staff send', () => {
  const disconnectedAt = channel({ status: 'disconnected' });

  it('lets a staff send through and restores connected when the provider accepts it', async () => {
    assert.equal(canStaffSendOnChannel(disconnectedAt), true, 'guard must not strand the channel');

    const rec = statusRecorder();
    const result = await trackOutboundProviderStatus(disconnectedAt, rec.setStatus, () =>
      Promise.resolve('wamid-recovered'),
    );

    assert.equal(result, 'wamid-recovered');
    assert.deepEqual(rec.writes, [{ id: 'ch-1', status: 'connected' }]);
  });

  it('leaves it disconnected and propagates when the credentials are still wrong', async () => {
    assert.equal(canStaffSendOnChannel(disconnectedAt), true);

    const rec = statusRecorder();
    const thrown = authError();

    await assert.rejects(
      () => trackOutboundProviderStatus(disconnectedAt, rec.setStatus, () => Promise.reject(thrown)),
      (err: unknown) => err === thrown,
    );

    // Already disconnected: no redundant write, and the channel stays retryable.
    assert.deepEqual(rec.writes, []);
    assert.equal(canStaffSendOnChannel(disconnectedAt), true, 'must remain retryable after a failure');
  });

  it('gives a disconnected Baileys channel neither the guard exemption nor the promotion', async () => {
    const disconnectedBaileys = channel({ provider: 'baileys', status: 'disconnected' });
    assert.equal(canStaffSendOnChannel(disconnectedBaileys), false);

    const rec = statusRecorder();
    await trackOutboundProviderStatus(disconnectedBaileys, rec.setStatus, () => Promise.resolve('ok'));
    assert.deepEqual(rec.writes, []);
  });
});
