import { describe, it } from 'node:test';
import assert from 'node:assert/strict';

// ticketMediaService imports db.ts, which throws without DATABASE_URL at import time;
// the postgres client connects lazily, so a dummy value is harmless.
process.env.DATABASE_URL ??= 'postgres://test:test@127.0.0.1:1/test';
const { MEDIA_MAX_BYTES } = await import('./ticketMediaService.js');
const { AfricasTalkingWebhookError } = await import('./africasTalkingWebhookError.js');
const {
  createMediaFetchLimiter,
  downloadInboundMedia,
  downloadWebhookMedia,
  WEBHOOK_MEDIA_CONCURRENCY,
  WEBHOOK_MEDIA_MAX_BYTES,
  WEBHOOK_MEDIA_QUEUE_LIMIT,
  WEBHOOK_MEDIA_QUEUE_WAIT_MS,
} = await import('./inboundMediaSecurity.js');

const isBusy = (err: unknown) =>
  err instanceof AfricasTalkingWebhookError
  && err.reason === 'media_busy'
  && err.statusCode === 503;

/** Let every already-resolved microtask and timer callback drain. */
const tick = (ms = 5) => new Promise(resolve => setTimeout(resolve, ms));

describe('createMediaFetchLimiter', () => {
  /** Runs `count` tasks that block on one gate, recording start order and peak concurrency. */
  function gatedTasks(run: <T>(task: () => Promise<T>) => Promise<T>, count: number) {
    const started: number[] = [];
    let active = 0;
    let peak = 0;
    let open!: () => void;
    const gate = new Promise<void>(resolve => { open = resolve; });
    const runs = Array.from({ length: count }, (_, i) => run(async () => {
      started.push(i);
      active += 1;
      peak = Math.max(peak, active);
      await gate;
      active -= 1;
      return i;
    }));
    return { runs, started, open, peak: () => peak, active: () => active };
  }

  it('sheds a waiter that passes the queue-wait deadline', async () => {
    const run = createMediaFetchLimiter(1, 4, 25);
    let open!: () => void;
    const held = new Promise<void>(resolve => { open = resolve; });
    const occupying = run(() => held);
    await tick();

    // The slot never frees inside the deadline, so this waiter must be shed, not left hanging.
    await assert.rejects(run(async () => 'never runs'), isBusy);

    open();
    await occupying;
  });

  it('stays usable after a waiter times out', async () => {
    const run = createMediaFetchLimiter(1, 4, 25);
    let open!: () => void;
    const held = new Promise<void>(resolve => { open = resolve; });
    const occupying = run(() => held);
    await tick();
    await assert.rejects(run(async () => 'shed'), isBusy);

    open();
    await occupying;
    assert.equal(await run(async () => 'reused'), 'reused');
  });

  it('neither loses nor duplicates a slot when waiters time out', async () => {
    const run = createMediaFetchLimiter(2, 2, 25);
    let openFirst!: () => void;
    const firstGate = new Promise<void>(resolve => { openFirst = resolve; });
    const occupying = [run(() => firstGate), run(() => firstGate)];
    await tick();

    // Both queue slots fill and both must time out without touching the two granted slots.
    await assert.rejects(run(async () => 'shed'), isBusy);
    await assert.rejects(run(async () => 'shed'), isBusy);

    openFirst();
    await Promise.all(occupying);

    // Capacity must be exactly 2 again: a lost slot would drop the peak, a duplicated one raise it.
    const after = gatedTasks(run, 3);
    await tick();
    assert.equal(after.peak(), 2);
    after.open();
    await Promise.all(after.runs);
    assert.equal(after.active(), 0);
    assert.equal(after.peak(), 2);
  });

  it('grants queued tasks in FIFO order without exceeding capacity', async () => {
    const run = createMediaFetchLimiter(2, 4, 1_000);
    const { runs, started, open, peak, active } = gatedTasks(run, 6);
    await tick();

    assert.deepEqual(started, [0, 1], 'only the two slots may start before any release');
    assert.equal(peak(), 2);

    open();
    assert.deepEqual(await Promise.all(runs), [0, 1, 2, 3, 4, 5]);
    assert.deepEqual(started, [0, 1, 2, 3, 4, 5], 'queued tasks start in arrival order');
    assert.equal(peak(), 2);
    assert.equal(active(), 0);
  });

  it('accepts exactly capacity + queue depth and sheds the next arrival', async () => {
    const run = createMediaFetchLimiter(2, 2, 1_000);
    const { runs, open } = gatedTasks(run, 4);
    await tick();

    await assert.rejects(run(async () => 'over'), isBusy);

    open();
    assert.equal((await Promise.all(runs)).length, 4);
  });

  it('is wired to one round of slots and a five second deadline', () => {
    assert.equal(WEBHOOK_MEDIA_CONCURRENCY, 4);
    assert.equal(
      WEBHOOK_MEDIA_QUEUE_LIMIT, WEBHOOK_MEDIA_CONCURRENCY,
      'queue depth is one round of slots, so a shed burst cannot outlive the callback',
    );
    assert.equal(WEBHOOK_MEDIA_QUEUE_WAIT_MS, 5_000);
  });
});

describe('downloadInboundMedia', () => {
  const publicLookup = async () => ['203.0.113.10'];

  function fetchReturning(body: BodyInit, init?: ResponseInit) {
    const urls: string[] = [];
    const inits: RequestInit[] = [];
    const fetchImpl = (async (url: string | URL | Request, reqInit?: RequestInit) => {
      urls.push(String(url));
      inits.push(reqInit ?? {});
      return new Response(body, init);
    }) as typeof fetch;
    return { fetchImpl, urls, inits };
  }

  it('downloads an HTTPS object, validates the MIME and returns base64', async () => {
    const { fetchImpl, urls, inits } = fetchReturning(Buffer.from('ABC'), {
      status: 200,
      headers: { 'Content-Type': 'image/png; charset=binary', 'Content-Length': '3' },
    });

    const media = await downloadInboundMedia('https://files.example.com/a.png', 'image', {
      fetchImpl,
      lookup: publicLookup,
    });

    assert.deepEqual(media, { base64: 'QUJD', mime: 'image/png', sizeBytes: 3 });
    assert.deepEqual(urls, ['https://files.example.com/a.png']);
    assert.equal(inits[0].redirect, 'error');
    assert.ok(inits[0].signal);
  });

  it('refuses non-HTTPS URLs', async () => {
    const { fetchImpl } = fetchReturning(Buffer.from('ABC'));
    for (const url of ['http://files.example.com/a.png', 'file:///etc/passwd', 'not a url']) {
      await assert.rejects(
        downloadInboundMedia(url, 'image', { fetchImpl, lookup: publicLookup }),
        (err: unknown) => err instanceof AfricasTalkingWebhookError && err.reason === 'media_rejected',
        url,
      );
    }
  });

  it('refuses loopback, private and link-local targets', async () => {
    const { fetchImpl, urls } = fetchReturning(Buffer.from('ABC'));
    const cases: Array<[string, string[]]> = [
      ['https://127.0.0.1/a.png', []],
      ['https://localhost/a.png', ['127.0.0.1']],
      ['https://10.0.0.5/a.png', []],
      ['https://192.168.1.10/a.png', []],
      ['https://169.254.169.254/latest/meta-data', ['169.254.169.254']],
      ['https://internal.example.com/a.png', ['203.0.113.10', '172.16.4.4']],
      ['https://[::1]/a.png', []],
      ['https://v6.example.com/a.png', ['fd00::1']],
    ];
    for (const [url, resolved] of cases) {
      await assert.rejects(
        downloadInboundMedia(url, 'image', { fetchImpl, lookup: async () => resolved }),
        (err: unknown) => err instanceof AfricasTalkingWebhookError && err.reason === 'media_rejected',
        url,
      );
    }
    assert.deepEqual(urls, []);
  });

  it('refuses IPv4 targets hidden in IPv6 literals and NAT64 prefixes', async () => {
    const { fetchImpl, urls } = fetchReturning(Buffer.from('ABC'));
    const cases: Array<[string, string[]]> = [
      ['https://[::ffff:7f00:1]/a.png', []],
      ['https://[::ffff:a9fe:a9fe]/latest/meta-data', []],
      ['https://[::ffff:c0a8:10a]/a.png', []],
      ['https://[::ffff:0a00:5]/a.png', []],
      ['https://[::7f00:1]/a.png', []],
      ['https://[64:ff9b::7f00:1]/a.png', []],
      ['https://[64:ff9b::a9fe:a9fe]/a.png', []],
      ['https://mapped.example.com/a.png', ['::ffff:7f00:1']],
      ['https://nat64.example.com/a.png', ['64:ff9b::a9fe:a9fe']],
      ['https://mixed.example.com/a.png', ['2606:4700::1111', '::ffff:c0a8:10a']],
    ];
    for (const [url, resolved] of cases) {
      await assert.rejects(
        downloadInboundMedia(url, 'image', { fetchImpl, lookup: async () => resolved }),
        (err: unknown) => err instanceof AfricasTalkingWebhookError && err.reason === 'media_rejected',
        url,
      );
    }
    assert.deepEqual(urls, []);
  });

  it('refuses IPv4 targets written as a dotted tail inside an IPv6 literal', async () => {
    const { fetchImpl, urls } = fetchReturning(Buffer.from('ABC'));
    const cases: Array<[string, string[]]> = [
      ['https://[::ffff:127.0.0.1]/a.png', []],
      ['https://[::ffff:192.168.1.10]/a.png', []],
      ['https://[::ffff:10.0.0.5]/a.png', []],
      ['https://[::ffff:169.254.169.254]/latest/meta-data', []],
      ['https://[::127.0.0.1]/a.png', []],
      ['https://dotted.example.com/a.png', ['::ffff:10.0.0.5']],
      ['https://dotted-meta.example.com/a.png', ['::ffff:169.254.169.254']],
    ];
    for (const [url, resolved] of cases) {
      await assert.rejects(
        downloadInboundMedia(url, 'image', { fetchImpl, lookup: async () => resolved }),
        (err: unknown) => err instanceof AfricasTalkingWebhookError && err.reason === 'media_rejected',
        url,
      );
    }
    assert.deepEqual(urls, []);
  });

  it('refuses 6to4 addresses that tunnel to an internal IPv4 target', async () => {
    const { fetchImpl, urls } = fetchReturning(Buffer.from('ABC'));
    const cases: Array<[string, string[]]> = [
      ['https://[2002:7f00:1::]/a.png', []],
      ['https://[2002:a9fe:a9fe::]/latest/meta-data', []],
      ['https://[2002:c0a8:10a::]/a.png', []],
      ['https://[2002:0a00:5::1]/a.png', []],
      ['https://sixtofour.example.com/a.png', ['2002:7f00:1::']],
      ['https://mixed6to4.example.com/a.png', ['2606:4700::1111', '2002:a9fe:a9fe::']],
    ];
    for (const [url, resolved] of cases) {
      await assert.rejects(
        downloadInboundMedia(url, 'image', { fetchImpl, lookup: async () => resolved }),
        (err: unknown) => err instanceof AfricasTalkingWebhookError && err.reason === 'media_rejected',
        url,
      );
    }
    assert.deepEqual(urls, []);
  });

  it('refuses Teredo and IPv4-translated prefixes that carry an IPv4 target', async () => {
    const { fetchImpl, urls } = fetchReturning(Buffer.from('ABC'));
    const cases: Array<[string, string[]]> = [
      // Teredo 2001:0::/32 reaches IPv4 through a relay, so the whole prefix is refused.
      ['https://[2001:0:4136:e378:8000:63bf:3fff:fdd2]/a.png', []],
      ['https://[2001:0::7f00:1]/a.png', []],
      ['https://teredo.example.com/a.png', ['2001:0:4136:e378:8000:63bf:3fff:fdd2']],
      // IPv4-translated ::ffff:0:0/96 embeds IPv4 the same way NAT64 does.
      ['https://[::ffff:0:127.0.0.1]/a.png', []],
      ['https://[::ffff:0:169.254.169.254]/latest/meta-data', []],
      ['https://[::ffff:0:203.0.113.10]/a.png', []],
      ['https://translated.example.com/a.png', ['::ffff:0:192.168.1.10']],
      ['https://mixedtranslated.example.com/a.png', ['2606:4700::1111', '::ffff:0:10.0.0.5']],
    ];
    for (const [url, resolved] of cases) {
      await assert.rejects(
        downloadInboundMedia(url, 'image', { fetchImpl, lookup: async () => resolved }),
        (err: unknown) => err instanceof AfricasTalkingWebhookError && err.reason === 'media_rejected',
        url,
      );
    }
    assert.deepEqual(urls, []);
  });

  it('does not refuse the rest of 2001::/16 alongside Teredo', async () => {
    const { fetchImpl, urls } = fetchReturning(Buffer.from('ABC'), {
      status: 200,
      headers: { 'Content-Type': 'image/png' },
    });
    // Only 2001:0::/32 is Teredo; ordinary global unicast under 2001: must still resolve.
    for (const address of ['2001:4860:4860::8888', '2001:db8:1::1']) {
      const media = await downloadInboundMedia('https://public6.example.com/a.png', 'image', {
        fetchImpl, lookup: async () => [address],
      });
      assert.equal(media.sizeBytes, 3, address);
    }
    assert.equal(urls.length, 2);
  });

  it('still allows public IPv6 hosts', async () => {
    const { fetchImpl, urls } = fetchReturning(Buffer.from('ABC'), {
      status: 200,
      headers: { 'Content-Type': 'image/png' },
    });
    const media = await downloadInboundMedia('https://[2606:4700::1111]/a.png', 'image', {
      fetchImpl,
      lookup: async () => ['2606:4700::1111'],
    });
    assert.equal(media.sizeBytes, 3);
    assert.equal(urls.length, 1);
  });

  it('refuses objects larger than the media limit by header or by stream', async () => {
    const declared = fetchReturning(Buffer.from('ABC'), {
      status: 200,
      headers: { 'Content-Type': 'image/png', 'Content-Length': '999999' },
    });
    await assert.rejects(
      downloadInboundMedia('https://files.example.com/a.png', 'image', {
        fetchImpl: declared.fetchImpl, lookup: publicLookup, maxBytes: 4,
      }),
      (err: unknown) => err instanceof AfricasTalkingWebhookError && err.reason === 'media_rejected',
    );

    const streamed = fetchReturning(Buffer.alloc(64, 1), {
      status: 200,
      headers: { 'Content-Type': 'image/png' },
    });
    await assert.rejects(
      downloadInboundMedia('https://files.example.com/a.png', 'image', {
        fetchImpl: streamed.fetchImpl, lookup: publicLookup, maxBytes: 8,
      }),
      (err: unknown) => err instanceof AfricasTalkingWebhookError && err.reason === 'media_rejected',
    );
  });

  it('refuses unsupported or missing content types and empty bodies', async () => {
    const bad = fetchReturning(Buffer.from('ABC'), {
      status: 200,
      headers: { 'Content-Type': 'video/mp4' },
    });
    await assert.rejects(
      downloadInboundMedia('https://files.example.com/a.mp4', 'image', {
        fetchImpl: bad.fetchImpl, lookup: publicLookup,
      }),
      (err: unknown) => err instanceof AfricasTalkingWebhookError && err.reason === 'media_rejected',
    );

    const missing = fetchReturning(Buffer.from('ABC'), { status: 200 });
    await assert.rejects(
      downloadInboundMedia('https://files.example.com/a.png', 'image', {
        fetchImpl: missing.fetchImpl, lookup: publicLookup,
      }),
      (err: unknown) => err instanceof AfricasTalkingWebhookError && err.reason === 'media_rejected',
    );

    const empty = fetchReturning(Buffer.alloc(0), {
      status: 200,
      headers: { 'Content-Type': 'image/png' },
    });
    await assert.rejects(
      downloadInboundMedia('https://files.example.com/a.png', 'image', {
        fetchImpl: empty.fetchImpl, lookup: publicLookup,
      }),
      (err: unknown) => err instanceof AfricasTalkingWebhookError && err.reason === 'media_rejected',
    );
  });

  it('caps public webhook downloads well below the internal media limit', async () => {
    assert.equal(WEBHOOK_MEDIA_MAX_BYTES, 16 * 1024 * 1024);
    assert.ok(WEBHOOK_MEDIA_MAX_BYTES < MEDIA_MAX_BYTES);

    const headers = {
      'Content-Type': 'image/png',
      'Content-Length': String(WEBHOOK_MEDIA_MAX_BYTES + 1),
    };
    const webhook = fetchReturning(Buffer.from('ABC'), { status: 200, headers });
    await assert.rejects(
      downloadWebhookMedia('https://files.example.com/a.png', 'image', {
        fetchImpl: webhook.fetchImpl, lookup: publicLookup,
      }),
      (err: unknown) => err instanceof AfricasTalkingWebhookError && err.reason === 'media_rejected',
    );

    // The same object is still within the internal (Baileys) limit, which stays unchanged.
    const internal = fetchReturning(Buffer.from('ABC'), { status: 200, headers });
    const media = await downloadInboundMedia('https://files.example.com/a.png', 'image', {
      fetchImpl: internal.fetchImpl, lookup: publicLookup,
    });
    assert.equal(media.sizeBytes, 3);
  });

  it('clamps the webhook cap so a caller cannot raise it', async () => {
    const headers = {
      'Content-Type': 'image/png',
      'Content-Length': String(WEBHOOK_MEDIA_MAX_BYTES + 1),
    };

    // A caller asking for the larger internal limit must still be held to the webhook ceiling.
    const declared = fetchReturning(Buffer.from('ABC'), { status: 200, headers });
    await assert.rejects(
      downloadWebhookMedia('https://files.example.com/a.png', 'image', {
        fetchImpl: declared.fetchImpl, lookup: publicLookup, maxBytes: MEDIA_MAX_BYTES,
      }),
      (err: unknown) => err instanceof AfricasTalkingWebhookError && err.reason === 'media_rejected',
    );

    // The streaming path is clamped too, even when the host declares no Content-Length.
    // The stream is generated a chunk at a time so the guard aborts it well before 16 MiB is buffered.
    const chunk = new Uint8Array(1024 * 1024);
    const oversized = new ReadableStream<Uint8Array>({
      pull(controller) {
        controller.enqueue(chunk);
      },
    });
    const streamedImpl = (async () =>
      new Response(oversized, {
        status: 200,
        headers: { 'Content-Type': 'image/png' },
      })) as typeof fetch;
    await assert.rejects(
      downloadWebhookMedia('https://files.example.com/a.png', 'image', {
        fetchImpl: streamedImpl, lookup: publicLookup, maxBytes: MEDIA_MAX_BYTES,
      }),
      (err: unknown) => err instanceof AfricasTalkingWebhookError && err.reason === 'media_rejected',
    );
  });

  it('lets a caller tighten, but never loosen, the webhook cap', async () => {
    const streamed = fetchReturning(Buffer.alloc(64, 1), {
      status: 200,
      headers: { 'Content-Type': 'image/png' },
    });
    await assert.rejects(
      downloadWebhookMedia('https://files.example.com/a.png', 'image', {
        fetchImpl: streamed.fetchImpl, lookup: publicLookup, maxBytes: 8,
      }),
      (err: unknown) => err instanceof AfricasTalkingWebhookError && err.reason === 'media_rejected',
    );

    const ok = fetchReturning(Buffer.from('ABC'), {
      status: 200,
      headers: { 'Content-Type': 'image/png' },
    });
    const media = await downloadWebhookMedia('https://files.example.com/a.png', 'image', {
      fetchImpl: ok.fetchImpl, lookup: publicLookup, maxBytes: 1024,
    });
    assert.equal(media.sizeBytes, 3);
  });

  it('sheds queued webhook downloads past the queue bound with a retryable error', async () => {
    assert.ok(WEBHOOK_MEDIA_QUEUE_LIMIT > 0);

    let open!: () => void;
    const gate = new Promise<void>(resolve => { open = resolve; });
    const fetchImpl = (async () => {
      await gate;
      return new Response(Buffer.from('ABC'), { status: 200, headers: { 'Content-Type': 'image/png' } });
    }) as typeof fetch;

    const start = () => downloadWebhookMedia('https://files.example.com/a.png', 'image', {
      fetchImpl, lookup: publicLookup,
    });

    // Fill every slot and the whole queue, then prove the next arrival is shed rather than queued.
    const accepted = Array.from(
      { length: WEBHOOK_MEDIA_CONCURRENCY + WEBHOOK_MEDIA_QUEUE_LIMIT },
      start,
    );
    await new Promise(resolve => setTimeout(resolve, 20));

    await assert.rejects(
      start(),
      (err: unknown) =>
        err instanceof AfricasTalkingWebhookError
        && err.reason === 'media_busy'
        && err.statusCode === 503,
    );

    open();
    const results = await Promise.all(accepted);
    assert.equal(results.length, WEBHOOK_MEDIA_CONCURRENCY + WEBHOOK_MEDIA_QUEUE_LIMIT);

    // The limiter must be reusable once the burst drains.
    const after = fetchReturning(Buffer.from('ABC'), {
      status: 200,
      headers: { 'Content-Type': 'image/png' },
    });
    const media = await downloadWebhookMedia('https://files.example.com/a.png', 'image', {
      fetchImpl: after.fetchImpl, lookup: publicLookup,
    });
    assert.equal(media.sizeBytes, 3);
  });

  it('runs at most four webhook media downloads at a time', async () => {
    assert.equal(WEBHOOK_MEDIA_CONCURRENCY, 4);

    let active = 0;
    let peak = 0;
    let open!: () => void;
    const gate = new Promise<void>(resolve => { open = resolve; });
    const fetchImpl = (async () => {
      active += 1;
      peak = Math.max(peak, active);
      await gate;
      active -= 1;
      return new Response(Buffer.from('ABC'), { status: 200, headers: { 'Content-Type': 'image/png' } });
    }) as typeof fetch;

    // Everything the limiter will admit at once: the slots plus one queued round.
    const admitted = WEBHOOK_MEDIA_CONCURRENCY + WEBHOOK_MEDIA_QUEUE_LIMIT;
    const runs = Array.from({ length: admitted }, () =>
      downloadWebhookMedia('https://files.example.com/a.png', 'image', { fetchImpl, lookup: publicLookup }));

    await new Promise(resolve => setTimeout(resolve, 20));
    assert.equal(peak, WEBHOOK_MEDIA_CONCURRENCY);

    open();
    const results = await Promise.all(runs);
    assert.equal(results.length, admitted);
    assert.equal(peak, WEBHOOK_MEDIA_CONCURRENCY);
    assert.equal(active, 0);
  });

  it('reports host failures and timeouts without leaking the URL', async () => {
    const failing = (async () => new Response('nope', { status: 500 })) as typeof fetch;
    await assert.rejects(
      downloadInboundMedia('https://files.example.com/secret-path.png', 'image', {
        fetchImpl: failing, lookup: publicLookup,
      }),
      (err: unknown) =>
        err instanceof AfricasTalkingWebhookError
        && err.reason === 'media_unavailable'
        && err.statusCode === 502
        && !err.message.includes('secret-path'),
    );

    const timeout = (async () => {
      throw Object.assign(new Error('aborted https://files.example.com/secret-path.png'), { name: 'TimeoutError' });
    }) as typeof fetch;
    await assert.rejects(
      downloadInboundMedia('https://files.example.com/secret-path.png', 'image', {
        fetchImpl: timeout, lookup: publicLookup,
      }),
      (err: unknown) =>
        err instanceof AfricasTalkingWebhookError
        && err.reason === 'media_unavailable'
        && !err.message.includes('secret-path'),
    );
  });
});
