/**
 * Africa's Talking inbound WhatsApp callbacks.
 *
 * Their public documentation does not pin one canonical callback body, so the mapper accepts a
 * fixed set of field aliases, rejects anything it cannot classify with certainty, and never echoes
 * payload content (or the channel token) in an error.
 *
 * The SSRF guard and bounded download for callback media live in `inboundMediaSecurity.ts` and are
 * re-exported here so existing callers keep one import site.
 */
import type { Channel } from '@wa-ticketing/shared';
import type { InboundResult, NormalizedWhatsAppInbound } from './whatsappInboundService.js';
import { AfricasTalkingWebhookError } from './africasTalkingWebhookError.js';
import { WEBHOOK_MEDIA_QUEUE_WAIT_MS, type InboundMediaKind } from './inboundMediaSecurity.js';

export { AfricasTalkingWebhookError } from './africasTalkingWebhookError.js';
export type { AfricasTalkingWebhookReason } from './africasTalkingWebhookError.js';
export {
  downloadInboundMedia,
  downloadWebhookMedia,
  WEBHOOK_MEDIA_CONCURRENCY,
  WEBHOOK_MEDIA_MAX_BYTES,
  WEBHOOK_MEDIA_QUEUE_LIMIT,
} from './inboundMediaSecurity.js';
export type { InboundMediaKind, MediaDownloadOptions } from './inboundMediaSecurity.js';

export type MappedAfricasTalkingInbound = {
  phone: string;
  waId: string;
  messageType: 'text' | InboundMediaKind;
  text?: string;
  mediaUrl?: string;
  mediaFileName?: string;
};

const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const MAX_TEXT_CHARS = 16_000;
const MAX_ID_CHARS = 256;
const MAX_URL_CHARS = 2048;

const MESSAGE_TYPES: Record<string, MappedAfricasTalkingInbound['messageType']> = {
  text: 'text',
  image: 'image',
  voice: 'voice_note',
  voice_note: 'voice_note',
  audio: 'voice_note',
  document: 'document',
};

type Path = readonly string[];

const PHONE_PATHS: Path[] = [['phoneNumber'], ['from'], ['mobile']];
const ID_PATHS: Path[] = [['messageId'], ['id'], ['body', 'messageId']];
const TYPE_PATHS: Path[] = [['type'], ['messageType'], ['mediaType'], ['body', 'type'], ['body', 'mediaType']];
const TEXT_PATHS: Path[] = [['text'], ['message'], ['body', 'message'], ['body', 'text']];
const CAPTION_PATHS: Path[] = [['caption'], ['body', 'caption'], ...TEXT_PATHS];
const MEDIA_URL_PATHS: Path[] = [['mediaUrl'], ['url'], ['body', 'url'], ['body', 'mediaUrl']];
const FILE_NAME_PATHS: Path[] = [['fileName'], ['filename'], ['body', 'fileName']];

function readPath(body: Record<string, unknown>, path: Path): string | null {
  let cursor: unknown = body;
  for (const key of path) {
    if (!cursor || typeof cursor !== 'object' || Array.isArray(cursor)) return null;
    cursor = (cursor as Record<string, unknown>)[key];
  }
  if (typeof cursor !== 'string') return null;
  const value = cursor.trim();
  return value ? value : null;
}

/** First non-empty alias value; conflicting aliases are a hard rejection rather than a guess. */
function pickAlias(body: Record<string, unknown>, paths: Path[]): string | null {
  const found: string[] = [];
  for (const path of paths) {
    const value = readPath(body, path);
    if (value && !found.includes(value)) found.push(value);
  }
  if (found.length > 1) {
    throw new AfricasTalkingWebhookError('ambiguous_payload', 400, 'Callback fields disagree');
  }
  return found[0] ?? null;
}

function invalid(): AfricasTalkingWebhookError {
  return new AfricasTalkingWebhookError('invalid_payload', 400, 'Callback payload is not a supported message');
}

function isPhoneLike(value: string): boolean {
  const digits = value.replace(/\D/g, '');
  return digits.length >= 6 && digits.length <= 20 && /^[+\d][\d\s\-()]*$/.test(value);
}

function asObject(raw: unknown): Record<string, unknown> {
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw invalid();
  return raw as Record<string, unknown>;
}

/** The channel number the callback was addressed to, when the provider supplies it. */
export function readAfricasTalkingWaNumber(raw: unknown): string | null {
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
  const value = (raw as Record<string, unknown>).waNumber;
  return typeof value === 'string' && value.trim() ? value.trim() : null;
}

export function samePhoneNumber(a: string, b: string): boolean {
  const digits = (v: string) => v.replace(/\D/g, '');
  return digits(a).length > 0 && digits(a) === digits(b);
}

/** Normalizes one callback into the fields the shared inbound processor understands. */
export function mapAfricasTalkingWebhook(raw: unknown): MappedAfricasTalkingInbound {
  const body = asObject(raw);

  const phone = pickAlias(body, PHONE_PATHS);
  if (!phone || phone.length > MAX_ID_CHARS || !isPhoneLike(phone)) throw invalid();

  const waId = pickAlias(body, ID_PATHS);
  if (!waId || waId.length > MAX_ID_CHARS) throw invalid();

  const typeRaw = pickAlias(body, TYPE_PATHS);
  const mediaUrl = pickAlias(body, MEDIA_URL_PATHS);
  const messageType = typeRaw
    ? MESSAGE_TYPES[typeRaw.toLowerCase()]
    : mediaUrl
      ? undefined
      : 'text';

  if (!messageType) {
    throw new AfricasTalkingWebhookError('unknown_event', 400, 'Unsupported callback event');
  }

  if (messageType === 'text') {
    if (mediaUrl) {
      throw new AfricasTalkingWebhookError('ambiguous_payload', 400, 'Callback fields disagree');
    }
    const text = pickAlias(body, TEXT_PATHS);
    if (!text || text.length > MAX_TEXT_CHARS) throw invalid();
    return { phone, waId, text, messageType };
  }

  if (!mediaUrl || mediaUrl.length > MAX_URL_CHARS) throw invalid();
  const caption = pickAlias(body, CAPTION_PATHS);
  if (caption && caption.length > MAX_TEXT_CHARS) throw invalid();
  const fileName = pickAlias(body, FILE_NAME_PATHS);

  return {
    phone,
    waId,
    ...(caption && { text: caption }),
    messageType,
    mediaUrl,
    ...(fileName && { mediaFileName: fileName.slice(0, MAX_ID_CHARS) }),
  };
}

/**
 * Callback kinds that are not inbound messages but are still normal, healthy provider traffic:
 * delivery receipts, status updates and read receipts all arrive on the same URL. Answering them
 * with 400 would make Africa's Talking read successful delivery as a broken endpoint and retry.
 */
const STATUS_EVENT_NAMES = new Set([
  'delivery', 'delivered', 'deliverystatus', 'deliveryreport', 'deliveryreceipt',
  'read', 'readreceipt', 'status', 'messagestatus', 'sent', 'failed', 'rejected',
  'expired', 'deleted',
]);

/** Values a `status`-style field carries when the callback is reporting on an earlier outbound. */
const DELIVERY_STATUS_VALUES = new Set([
  'sent', 'delivered', 'read', 'failed', 'rejected', 'expired', 'queued', 'submitted',
]);

const EVENT_NAME_PATHS: Path[] = [['eventType'], ['event'], ['statusType'], ['body', 'eventType'], ['body', 'event']];
/** Every field that may hold either a message type or an event name; both readings are checked. */
const TYPE_EVENT_PATHS: Path[] = [...TYPE_PATHS, ['body', 'messageType']];
const STATUS_VALUE_PATHS: Path[] = [['status'], ['deliveryStatus'], ['body', 'status'], ['body', 'deliveryStatus']];
const CONTENT_PATHS: Path[] = [...TEXT_PATHS, ...CAPTION_PATHS, ...MEDIA_URL_PATHS];

/** Comparison form for provider labels: case, spacing and separators all vary between payloads. */
const normalizeLabel = (value: string): string => value.toLowerCase().replace(/[^a-z]/g, '');

function firstMatch(body: Record<string, unknown>, paths: Path[]): string | null {
  for (const path of paths) {
    const value = readPath(body, path);
    if (value) return value;
  }
  return null;
}

function isStatusEvent(body: Record<string, unknown>): boolean {
  // `type`, `messageType` and `mediaType` all carry either a message type or an event name. A known
  // message type is decisive only when the callback has actual content; delivery receipts may echo
  // the original type without carrying a new message.
  const typeValues = TYPE_EVENT_PATHS.map(path => readPath(body, path)).filter((v): v is string => Boolean(v));
  if (typeValues.some(value => MESSAGE_TYPES[value.toLowerCase()]) && firstMatch(body, CONTENT_PATHS)) return false;

  const eventName = firstMatch(body, EVENT_NAME_PATHS);
  if (eventName && STATUS_EVENT_NAMES.has(normalizeLabel(eventName))) return true;

  if (typeValues.some(value => STATUS_EVENT_NAMES.has(normalizeLabel(value)))) return true;

  // A delivery status value is only decisive when there is nothing to store: a real inbound
  // message that happens to carry a `status` field is still a message.
  const status = firstMatch(body, STATUS_VALUE_PATHS);
  return Boolean(
    status
    && DELIVERY_STATUS_VALUES.has(normalizeLabel(status))
    && !firstMatch(body, CONTENT_PATHS),
  );
}

export type AfricasTalkingCallbackClassification =
  | { kind: 'message'; message: MappedAfricasTalkingInbound }
  | { kind: 'ignored'; reason: 'unsupported_event' };

/**
 * Sorts one callback into "a message we must store" or "an event we acknowledge and drop".
 *
 * A typed result rather than a thrown-and-inspected error, so the caller distinguishes a recognized
 * non-message event from a payload we genuinely could not read — the latter still throws a 400.
 */
export function classifyAfricasTalkingCallback(raw: unknown): AfricasTalkingCallbackClassification {
  const body = asObject(raw);
  if (isStatusEvent(body)) return { kind: 'ignored', reason: 'unsupported_event' };
  return { kind: 'message', message: mapAfricasTalkingWebhook(body) };
}

/**
 * `Retry-After` seconds for a response body from `handleAfricasTalkingWebhook`, or null when the
 * response needs no hint. Only a shed media fetch is worth retrying on a fixed delay: the caller
 * waited out the queue deadline, so the same wait is the honest advice. Pure, so the route can set
 * the header in one explicit line.
 */
export function webhookRetryAfterSeconds(body: unknown): number | null {
  if (!body || typeof body !== 'object' || Array.isArray(body)) return null;
  const { reason } = body as { reason?: unknown };
  return reason === 'media_busy' ? Math.ceil(WEBHOOK_MEDIA_QUEUE_WAIT_MS / 1000) : null;
}

export type AfricasTalkingWebhookDeps = {
  /** Must only resolve `kind = 'whatsapp' AND provider = 'africas_talking'` channels. */
  findChannel: (token: string) => Promise<Channel | null>;
  isDuplicate: (waId: string) => Promise<boolean>;
  processInbound: (input: NormalizedWhatsAppInbound) => Promise<InboundResult>;
  downloadMedia: (url: string, kind: InboundMediaKind) => Promise<{ base64: string; mime: string }>;
};

/** Token resolution, replay/number checks and media fetch for one public callback. */
export async function handleAfricasTalkingWebhook(
  token: string,
  rawBody: unknown,
  deps: AfricasTalkingWebhookDeps,
): Promise<{ status: number; body: unknown }> {
  if (!UUID_RE.test(token)) return { status: 404, body: { error: 'Unknown token' } };

  const channel = await deps.findChannel(token);
  if (!channel || channel.kind !== 'whatsapp' || channel.provider !== 'africas_talking') {
    return { status: 404, body: { error: 'Unknown token' } };
  }

  try {
    const waNumber = readAfricasTalkingWaNumber(rawBody);
    if (waNumber && !samePhoneNumber(waNumber, channel.phone_number)) {
      throw new AfricasTalkingWebhookError('channel_mismatch', 400, 'Callback is for a different channel number');
    }

    const classified = classifyAfricasTalkingCallback(rawBody);
    if (classified.kind === 'ignored') {
      return { status: 200, body: { ok: true, ignored: true, reason: classified.reason } };
    }

    const mapped = classified.message;
    if (await deps.isDuplicate(mapped.waId)) {
      return { status: 200, body: { ok: true, duplicate: true } };
    }

    const input: NormalizedWhatsAppInbound = {
      channelId: channel.id,
      phone: mapped.phone,
      waId: mapped.waId,
      ...(mapped.text && { text: mapped.text }),
      messageType: mapped.messageType,
      ...(mapped.mediaFileName && { mediaFileName: mapped.mediaFileName }),
    };

    if (mapped.messageType !== 'text') {
      const media = await deps.downloadMedia(mapped.mediaUrl!, mapped.messageType);
      input.mediaBase64 = media.base64;
      input.mediaMime = media.mime;
    }

    return { status: 200, body: await deps.processInbound(input) };
  } catch (err) {
    if (err instanceof AfricasTalkingWebhookError) {
      return { status: err.statusCode, body: { error: err.message, reason: err.reason } };
    }
    throw err;
  }
}
