/**
 * Phase 6 - IMAP bounce parser library.
 *
 * Walks an inbound delivery-status notification (DSN / NDR) message and
 * extracts the original recipient, bounce class (hard / soft / ooo /
 * unknown), and a human-readable reason. The cron poller in
 * `src/jobs/processors/imap-bounce-poller.ts` (deferred to Phase 6b)
 * passes each fetched message through this parser, then matches the
 * extracted recipient against `document_sends.recipient_email` within a
 * 7-day window before updating the send row's bounce_* columns.
 *
 * The parser handles three common NDR shapes:
 *   1. RFC 3464 multipart/report - Postfix, Exim, Sendmail, Gmail. The
 *      message has a `message/delivery-status` part whose headers carry
 *      structured Action / Status / Original-Recipient fields.
 *   2. Microsoft Outlook / Exchange - non-DSN reports with the original
 *      recipient embedded in the subject line + body prose.
 *   3. Out-of-office auto-replies - distinct from bounces; classed as
 *      `ooo` so the UI banner stays informational rather than alarming.
 *
 * When the parser can't extract a recipient it returns
 * `originalRecipient: null` + `bounceClass: 'unknown'` so the worker can
 * log + audit without mutating any send rows.
 */

import { simpleParser, type ParsedMail } from 'mailparser';

export type BounceClass = 'hard' | 'soft' | 'ooo' | 'unknown';

export interface ParsedBounce {
  originalRecipient: string | null;
  bounceClass: BounceClass;
  /** Concise human-readable reason surfaced in the UI banner. */
  reason: string;
  /** Inbound message-id (or in-reply-to header) for cross-reference. */
  inReplyTo: string | null;
  /**
   * Candidate Message-IDs of the *original* (bounced) message, gathered
   * from the NDR's `In-Reply-To` + `References` headers AND from the
   * returned-headers block embedded in the DSN body (RFC 3464 carries the
   * failed message's headers in a `message/rfc822` part). The poller
   * matches these against `document_sends.message_id` to pin a bounce to
   * the exact originating send (and therefore the correct port) without
   * having to guess by recipient + time window. Angle brackets/whitespace
   * are stripped and values lowercased so comparison is normalization-safe.
   */
  originalMessageIds: string[];
  /** SMTP status code (e.g. "5.1.1") if the NDR carried one. */
  statusCode: string | null;
}

/**
 * RFC 5322-ish address validator. Deliberately identical to the strict
 * regex used on the outbound side (`document-sends.service.ts`) so a
 * recipient that was rejected at send time can't slip through here either.
 * The `originalRecipient` we parse comes from an attacker-controllable
 * inbound NDR body, so it MUST be validated before it lands in a DB query
 * or a user-facing notification string (audit L16a).
 */
const RFC5322_EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

function isValidEmail(email: string | null | undefined): email is string {
  return Boolean(email) && email!.length <= 254 && RFC5322_EMAIL.test(email!);
}

/** Strip angle brackets + whitespace and lowercase a Message-ID for
 *  normalization-safe equality. Returns null for empty/garbage input. */
function normalizeMessageId(raw: string | null | undefined): string | null {
  if (!raw) return null;
  const trimmed = raw
    .trim()
    .replace(/^<+|>+$/g, '')
    .trim()
    .toLowerCase();
  // A Message-ID must contain an '@' and have no internal whitespace; reject
  // anything that doesn't look like one so we never query on noise.
  if (!trimmed || /\s/.test(trimmed) || !trimmed.includes('@')) return null;
  return trimmed;
}

/**
 * Collect every plausible original-message-id from the parsed NDR:
 *   1. `In-Reply-To` (single id) and `References` (one or many) headers.
 *   2. The returned-headers block in the DSN body — many MTAs inline the
 *      failed message's `Message-ID:` header inside the human-readable or
 *      `message/rfc822` part rather than setting In-Reply-To on the NDR.
 * Deduplicated + normalized.
 */
function collectOriginalMessageIds(
  inReplyTo: string | null,
  references: string[] | string | undefined,
  bodyText: string,
): string[] {
  const out = new Set<string>();
  const push = (v: string | null | undefined): void => {
    const n = normalizeMessageId(v);
    if (n) out.add(n);
  };

  push(inReplyTo);
  if (Array.isArray(references)) {
    for (const r of references) push(r);
  } else if (typeof references === 'string') {
    // A single `References` header may carry space-separated ids.
    for (const r of references.split(/\s+/)) push(r);
  }

  // Returned `Message-ID:` header(s) embedded in the DSN body.
  const re = /^\s*Message-ID:\s*(<[^>\r\n]+>)/gim;
  let m: RegExpExecArray | null;
  while ((m = re.exec(bodyText)) !== null) {
    push(m[1]);
  }

  return [...out];
}

const HARD_BOUNCE_STATUSES = new Set([
  '5.0.0',
  '5.1.1', // mailbox does not exist
  '5.1.2', // bad destination system
  '5.1.3', // bad destination mailbox address syntax
  '5.1.10', // recipient address rejected
  '5.7.1', // delivery not authorized
  '5.7.26', // multiple authentication checks failed
  '5.4.1', // no answer from host
]);

const SOFT_BOUNCE_STATUSES = new Set([
  '4.2.2', // mailbox full
  '4.4.1', // no answer from host (transient)
  '4.4.7', // delivery time expired
  '4.7.0', // generic transient
  '5.2.2', // mailbox full (some servers send 5.x for full)
]);

function classifyByStatus(status: string | null): BounceClass | null {
  if (!status) return null;
  if (HARD_BOUNCE_STATUSES.has(status)) return 'hard';
  if (SOFT_BOUNCE_STATUSES.has(status)) return 'soft';
  // First-digit fallback: 5xx hard, 4xx soft.
  if (status.startsWith('5.')) return 'hard';
  if (status.startsWith('4.')) return 'soft';
  return null;
}

function looksLikeOoo(subject: string, bodyText: string): boolean {
  const sig = `${subject}\n${bodyText}`.toLowerCase();
  return (
    /(out\s*of\s*office|out-of-office|auto[-\s]?reply|on\s*vacation|on\s*holiday|away\s*from\s*my\s*desk)/.test(
      sig,
    ) && !/delivery (status|failure)/.test(sig)
  );
}

function extractStatusFromBody(text: string): string | null {
  // Postfix / generic: "Status: 5.1.1"
  const direct = text.match(/Status:\s*(\d\.\d+\.\d+)/i);
  if (direct?.[1]) return direct[1];
  // SMTP reply line: "550 5.1.1 user unknown"
  const reply = text.match(/\b([45]\d{2})\s+(\d\.\d+\.\d+)/);
  if (reply?.[2]) return reply[2];
  // Bare 550/451/etc.
  const bare = text.match(/\b(550|551|552|553|554|450|451|452)\b/);
  if (bare?.[1]) {
    const code = bare[1];
    return `${code[0]}.0.0`;
  }
  return null;
}

function extractRecipientFromBody(text: string): string | null {
  // RFC 3464 explicit field.
  const explicit = text.match(
    /(?:Original-)?Recipient(?:\s*\(rfc822\))?:\s*(?:rfc822;)?\s*([^\s<>]+@[^\s<>]+)/i,
  );
  if (explicit?.[1]) return explicit[1].trim();
  // "Failed delivery to <user@example.com>" / "couldn't be delivered to user@example.com"
  const angled = text.match(
    /(?:delivered\s+to|delivery\s+to|sent\s+to|failed\s+for|recipient[s]?:)\s*<?([^\s<>"]+@[^\s<>"]+)/i,
  );
  if (angled?.[1]) return angled[1].trim();
  return null;
}

export async function parseBounce(raw: string | Buffer): Promise<ParsedBounce> {
  let parsed: ParsedMail;
  try {
    parsed = await simpleParser(raw);
  } catch {
    return {
      originalRecipient: null,
      bounceClass: 'unknown',
      reason: 'Failed to parse message',
      inReplyTo: null,
      originalMessageIds: [],
      statusCode: null,
    };
  }

  const subject = parsed.subject ?? '';
  const bodyText = parsed.text ?? '';
  const inReplyTo = (parsed.inReplyTo as string | undefined) ?? null;
  const originalMessageIds = collectOriginalMessageIds(inReplyTo, parsed.references, bodyText);

  if (looksLikeOoo(subject, bodyText)) {
    const oooFrom = parsed.from?.value[0]?.address ?? null;
    return {
      // OOO auto-replies come *from* the recipient, so the From address is
      // the "original recipient". Validate it the same way as a bounce
      // recipient (audit L16a) - an invalid value would only ever pollute a
      // string we don't act on for OOO, but we keep the contract uniform.
      originalRecipient: isValidEmail(oooFrom) ? oooFrom : null,
      bounceClass: 'ooo',
      reason: 'Out-of-office auto-reply',
      inReplyTo,
      originalMessageIds,
      statusCode: null,
    };
  }

  // Try to walk the multipart/report DSN structure first; falls back to
  // plain-text heuristics for non-RFC-compliant Outlook NDRs.
  const statusCode = extractStatusFromBody(bodyText);
  const rawRecipient = extractRecipientFromBody(bodyText);
  // The recipient is parsed out of an attacker-controllable inbound body;
  // reject anything that isn't a syntactically valid address before it can
  // reach a DB query or a notification string (audit L16a).
  const originalRecipient = isValidEmail(rawRecipient) ? rawRecipient : null;

  const cls = classifyByStatus(statusCode);

  // Subject heuristics when status code missing.
  const subjectLower = subject.toLowerCase();
  const looksLikeBounce =
    /(undelivered|delivery (status|failure)|returned mail|mail delivery|undeliverable|failure notice)/.test(
      subjectLower,
    );

  if (!cls && !looksLikeBounce) {
    return {
      originalRecipient,
      bounceClass: 'unknown',
      reason: 'No bounce indicators detected',
      inReplyTo,
      originalMessageIds,
      statusCode,
    };
  }

  return {
    originalRecipient,
    bounceClass: cls ?? 'hard',
    reason: deriveReason(statusCode, bodyText, subject),
    inReplyTo,
    originalMessageIds,
    statusCode,
  };
}

function deriveReason(status: string | null, bodyText: string, subject: string): string {
  if (status === '5.1.1') return 'Recipient address does not exist';
  if (status === '5.2.2' || status === '4.2.2') return 'Recipient mailbox is full';
  if (status === '5.7.1') return 'Delivery not authorized (likely anti-spam block)';
  if (status === '4.4.1' || status === '4.4.7') return 'Transient delivery failure';
  // Grab the first line of the SMTP diagnostic if present.
  const diag = bodyText.match(/Diagnostic-Code:[^\n]+/i)?.[0];
  if (diag)
    return diag
      .replace(/^Diagnostic-Code:\s*smtp;?\s*/i, '')
      .trim()
      .slice(0, 200);
  return subject.slice(0, 200);
}
