/**
 * SSRF guard and bounded download for media referenced by an inbound provider callback.
 *
 * A callback body is attacker-controlled, so its media URL is treated as hostile: HTTPS only, no
 * redirects, no private/loopback/tunnelled destination, a hard byte ceiling, and a bounded number
 * of in-flight fetches so a callback burst cannot exhaust heap or sockets.
 *
 * Residual risk — DNS rebinding: the host is resolved here for the allow/deny decision and then
 * resolved again by `fetch` when the connection is made. A record that changes between those two
 * lookups (short TTL, attacker-controlled zone) can still point the actual connection at a private
 * address. Closing this needs connect-time pinning (a custom dispatcher/agent that dials the
 * already-validated IP), which is deliberately out of scope here. The blast radius is limited by
 * the HTTPS-only rule, `redirect: 'error'`, the byte cap, and the fact that response bodies are
 * only ever stored as ticket media and never interpreted.
 */
import { lookup as dnsLookup } from 'node:dns/promises';
import { isIP } from 'node:net';
import { MEDIA_MAX_BYTES, normalizeMime, validateInboundMime } from './ticketMediaService.js';
import { AfricasTalkingWebhookError } from './africasTalkingWebhookError.js';

export type InboundMediaKind = 'image' | 'voice_note' | 'document';

const MEDIA_TIMEOUT_MS = 20_000;

function isPrivateIpv4(address: string): boolean {
  const [a, b] = address.split('.').map(Number);
  if (a === 0 || a === 10 || a === 127) return true;
  if (a === 169 && b === 254) return true;
  if (a === 172 && b >= 16 && b <= 31) return true;
  if (a === 192 && b === 168) return true;
  if (a === 100 && b >= 64 && b <= 127) return true;
  if (a === 192 && b === 0) return true;
  if (a === 198 && (b === 18 || b === 19)) return true;
  if (a >= 224) return true;
  return false;
}

/** Eight 16-bit groups, or null when the literal cannot be read (caller then fails closed). */
function expandIpv6(address: string): number[] | null {
  const literal = address.toLowerCase().split('%')[0];
  const [head, tail, ...rest] = literal.split('::');
  if (rest.length) return null;

  const toGroups = (part: string): number[] | null => {
    if (!part) return [];
    const parts = part.split(':');
    const groups: number[] = [];
    for (const [index, piece] of parts.entries()) {
      // A dotted-quad tail (::ffff:127.0.0.1) occupies the last two groups.
      if (index === parts.length - 1 && piece.includes('.')) {
        if (isIP(piece) !== 4) return null;
        const [a, b, c, d] = piece.split('.').map(Number);
        groups.push((a << 8) | b, (c << 8) | d);
        continue;
      }
      if (!/^[0-9a-f]{1,4}$/.test(piece)) return null;
      groups.push(parseInt(piece, 16));
    }
    return groups;
  };

  const left = toGroups(head);
  const right = tail === undefined ? [] : toGroups(tail);
  if (!left || !right) return null;
  if (tail === undefined) return left.length === 8 ? left : null;
  const gap = 8 - left.length - right.length;
  if (gap < 0) return null;
  return [...left, ...Array<number>(gap).fill(0), ...right];
}

const asIpv4 = (high: number, low: number) =>
  `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`;

function isPrivateAddress(address: string): boolean {
  const version = isIP(address);
  if (version === 4) return isPrivateIpv4(address);
  if (version !== 6) return true;

  const groups = expandIpv6(address);
  if (!groups) return true;
  if (groups.every(group => group === 0)) return true;
  if ((groups[0] & 0xfe00) === 0xfc00) return true;
  if ((groups[0] & 0xffc0) === 0xfe80) return true;
  if ((groups[0] & 0xff00) === 0xff00) return true;
  // Prefixes that exist to carry an IPv4 destination are refused whole, without reading the
  // embedded address: none of them is a legitimate host for provider-hosted media.
  // NAT64 64:ff9b::/32 (covers the well-known /96 and the local-use 64:ff9b:1::/48).
  if (groups[0] === 0x0064 && groups[1] === 0xff9b) return true;
  // 6to4 2002::/16.
  if (groups[0] === 0x2002) return true;
  // Teredo 2001:0::/32 — only this /32, so the rest of 2001::/16 stays reachable.
  if (groups[0] === 0x2001 && groups[1] === 0x0000) return true;
  // IPv4-translated ::ffff:0:0/96 (RFC 2765), i.e. ::ffff:0:a.b.c.d.
  if (groups.slice(0, 4).every(group => group === 0) && groups[4] === 0xffff && groups[5] === 0x0000) {
    return true;
  }

  const prefixIsZero = groups.slice(0, 5).every(group => group === 0);
  // IPv4-mapped (::ffff:a.b.c.d / ::ffff:hhhh:hhhh) and the deprecated IPv4-compatible ::a.b.c.d.
  if (prefixIsZero && (groups[5] === 0xffff || groups[5] === 0)) {
    return isPrivateIpv4(asIpv4(groups[6], groups[7]));
  }
  return false;
}

export type MediaDownloadOptions = {
  fetchImpl?: typeof fetch;
  lookup?: (hostname: string) => Promise<string[]>;
  maxBytes?: number;
  timeoutMs?: number;
};

const defaultLookup = async (hostname: string): Promise<string[]> =>
  (await dnsLookup(hostname, { all: true, verbatim: true })).map(entry => entry.address);

function mediaRejected(message: string): AfricasTalkingWebhookError {
  return new AfricasTalkingWebhookError('media_rejected', 400, message);
}

async function assertPublicHttpsTarget(
  rawUrl: string,
  lookup: (hostname: string) => Promise<string[]>,
): Promise<void> {
  let url: URL;
  try {
    url = new URL(rawUrl);
  } catch {
    throw mediaRejected('Inbound media URL is not usable');
  }
  if (url.protocol !== 'https:') throw mediaRejected('Inbound media must be served over HTTPS');

  const host = url.hostname.replace(/^\[|\]$/g, '');
  const addresses = isIP(host) ? [host] : await lookup(host).catch(() => []);
  if (!addresses.length || addresses.some(isPrivateAddress)) {
    throw mediaRejected('Inbound media host is not allowed');
  }
}

/** Bounded, redirect-free HTTPS download of one inbound attachment. */
export async function downloadInboundMedia(
  url: string,
  kind: InboundMediaKind,
  options: MediaDownloadOptions = {},
): Promise<{ base64: string; mime: string; sizeBytes: number }> {
  const fetchImpl = options.fetchImpl ?? fetch;
  const maxBytes = options.maxBytes ?? MEDIA_MAX_BYTES;
  await assertPublicHttpsTarget(url, options.lookup ?? defaultLookup);

  let res: Response;
  try {
    res = await fetchImpl(url, {
      method: 'GET',
      redirect: 'error',
      signal: AbortSignal.timeout(options.timeoutMs ?? MEDIA_TIMEOUT_MS),
    });
  } catch {
    throw new AfricasTalkingWebhookError('media_unavailable', 502, 'Inbound media could not be downloaded');
  }
  if (!res.ok) {
    throw new AfricasTalkingWebhookError('media_unavailable', 502, 'Inbound media could not be downloaded');
  }

  const declared = Number(res.headers.get('content-length') ?? '');
  if (Number.isFinite(declared) && declared > maxBytes) {
    throw mediaRejected('Inbound media exceeds the media size limit');
  }

  const contentType = res.headers.get('content-type');
  if (!contentType) throw mediaRejected('Inbound media has no content type');
  const mime = normalizeMime(contentType);
  try {
    validateInboundMime(mime, kind);
  } catch {
    throw mediaRejected('Inbound media type is not supported');
  }

  const reader = res.body?.getReader();
  if (!reader) throw mediaRejected('Inbound media response had no body');
  const chunks: Buffer[] = [];
  let total = 0;
  for (;;) {
    const { done, value } = await reader.read();
    if (done) break;
    total += value.byteLength;
    if (total > maxBytes) {
      await reader.cancel().catch(() => {});
      throw mediaRejected('Inbound media exceeds the media size limit');
    }
    chunks.push(Buffer.from(value));
  }
  if (!total) throw mediaRejected('Inbound media was empty');

  return { base64: Buffer.concat(chunks).toString('base64'), mime, sizeBytes: total };
}

/** Public callbacks are untrusted: a tighter cap than the internal (Baileys) media limit. */
export const WEBHOOK_MEDIA_MAX_BYTES = 16 * 1024 * 1024;
/** Process-local ceiling so a callback burst queues instead of multiplying heap and sockets. */
export const WEBHOOK_MEDIA_CONCURRENCY = 4;
/**
 * Queue depth past the slots — one further round, no more.
 *
 * A deep queue is worse than shedding. A callback held long enough to outlive the provider's own
 * request timeout gets redelivered, and the redelivery can clear the `isDuplicate` fast path before
 * the original finishes persisting. Storage stays correct either way — `messages.wa_message_id` is
 * UNIQUE, so the loser of that race cannot store a second copy — but both copies download the media
 * for nothing, and the losing insert raises a unique violation that surfaces as a transient 500. The
 * provider retries, by then the first copy is committed, and the callback is finally acknowledged as
 * a duplicate. Shallow queue plus deadline keeps a queued callback inside the window where it is
 * still the only copy in flight, so that detour does not happen.
 */
export const WEBHOOK_MEDIA_QUEUE_LIMIT = WEBHOOK_MEDIA_CONCURRENCY;
/** How long a callback may wait for a slot before it is shed as retryable. */
export const WEBHOOK_MEDIA_QUEUE_WAIT_MS = 5_000;

function mediaBusy(): AfricasTalkingWebhookError {
  // 503 rather than a silent drop: the provider should redeliver this callback.
  return new AfricasTalkingWebhookError(
    'media_busy', 503, 'Inbound media fetch is busy, retry shortly');
}

/**
 * Concurrency limiter with a bounded, deadlined FIFO queue.
 *
 * A slot is handed straight from a finishing task to the next waiter, so `granted` only changes
 * when a slot is genuinely created or retired. Each waiter settles exactly once — whichever of the
 * handoff and the deadline comes first wins — so a waiter shed on the deadline never abandons a
 * slot it was just given, and never retires one it never held.
 *
 * Exported so its ownership rules can be tested directly with a short deadline.
 */
export function createMediaFetchLimiter(capacity: number, queueLimit: number, queueWaitMs: number) {
  /** `settle` reports whether this call is the one that settled the waiter. */
  type Waiter = { settle: (granted: boolean) => boolean };
  let granted = 0;
  const waiting: Waiter[] = [];

  const acquire = async (): Promise<void> => {
    if (granted < capacity) {
      granted += 1;
      return;
    }
    if (waiting.length >= queueLimit) throw mediaBusy();

    await new Promise<void>((resolve, reject) => {
      let settled = false;
      let timer: NodeJS.Timeout;
      const waiter: Waiter = {
        settle: got => {
          if (settled) return false;
          settled = true;
          clearTimeout(timer);
          if (got) resolve();
          else reject(mediaBusy());
          return true;
        },
      };
      waiting.push(waiter);
      timer = setTimeout(() => {
        // Leaving the queue before any handoff: this waiter owns no slot, so `granted` is untouched.
        const index = waiting.indexOf(waiter);
        if (index >= 0) waiting.splice(index, 1);
        waiter.settle(false);
      }, queueWaitMs);
    });
  };

  /** Hand the slot to the first waiter that can still take it, else retire it. */
  const release = (): void => {
    for (;;) {
      const next = waiting.shift();
      if (!next) {
        granted -= 1;
        return;
      }
      if (next.settle(true)) return;
    }
  };

  return async function run<T>(task: () => Promise<T>): Promise<T> {
    await acquire();
    try {
      return await task();
    } finally {
      release();
    }
  };
}

const withMediaSlot = createMediaFetchLimiter(
  WEBHOOK_MEDIA_CONCURRENCY, WEBHOOK_MEDIA_QUEUE_LIMIT, WEBHOOK_MEDIA_QUEUE_WAIT_MS);

/**
 * Media fetch for public webhooks: the webhook byte cap plus the shared concurrency limit.
 * A caller may tighten `maxBytes` but never raise it above the webhook ceiling.
 */
export function downloadWebhookMedia(
  url: string,
  kind: InboundMediaKind,
  options: MediaDownloadOptions = {},
): Promise<{ base64: string; mime: string; sizeBytes: number }> {
  const maxBytes = Math.min(options.maxBytes ?? WEBHOOK_MEDIA_MAX_BYTES, WEBHOOK_MEDIA_MAX_BYTES);
  return withMediaSlot(() => downloadInboundMedia(url, kind, { ...options, maxBytes }));
}
