/** Bounce / complaint / unsubscribe webhook payload shape. */
export type SuppressionEvent = {
  type: 'bounce' | 'complaint' | 'unsubscribe'
  email: string
  provider?: string
  raw?: unknown
}

export type MailMessage = {
  from: string
  to: string | string[]
  replyTo?: string
  cc?: string | string[]
  bcc?: string | string[]
  subject: string
  html?: string
  text?: string
  headers?: Record<string, string>
  tags?: Record<string, string>
  /**
   * Provider-side dedup key — forwarded to the provider's native idempotency
   * mechanism where one exists (resend → Idempotency-Key header; brevo →
   * Idempotency-Key header). Postmark and SES have NO API-level idempotency, so
   * this field is dropped for those adapters (documented in each).
   *
   * FOOTGUN: combined with `retry` (CreateMailOpts), a retry against a
   * non-supporting provider (postmark/ses) can double-send. For those, dedup the
   * send-job one layer up via jobs' IdempotencyStore at dispatch — mail does not
   * own cross-provider exactly-once.
   */
  idempotencyKey?: string
}

export type MailResult = {
  id: string
  provider: string
}

export type MailAdapter = {
  send(msg: MailMessage): Promise<MailResult>
}

export class MailError extends Error {}

export class MailValidationError extends MailError {
  override readonly name = 'MailValidationError'

  constructor(
    message: string,
    readonly field?: string,
  ) {
    super(message)
  }
}

export class MailProviderError extends MailError {
  override readonly name = 'MailProviderError'

  constructor(
    message: string,
    readonly provider: string,
    readonly retryable = false,
    readonly cause?: unknown,
  ) {
    super(message)
  }
}

/** RFC5322-shaped local-part@domain (angle-addr and display-name tolerated). */
const EMAIL_RE =
  /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/

function extractAddress(raw: string): string {
  const angle = raw.match(/<([^>]+)>/)
  return (angle?.[1] ?? raw).trim()
}

function isValidAddress(raw: string): boolean {
  const addr = extractAddress(raw)
  return addr.length > 0 && EMAIL_RE.test(addr)
}

function validateAddressField(
  value: string | string[] | undefined,
  field: string,
): void {
  if (value === undefined) return
  const list = Array.isArray(value) ? value : [value]
  for (const entry of list) {
    if (!isValidAddress(entry)) {
      throw new MailValidationError(`invalid ${field} address: ${entry}`, field)
    }
  }
}

/** Trust-boundary floor — malformed addresses never reach an adapter. */
export function validateMessage(msg: MailMessage): void {
  if (!msg.from?.trim()) {
    throw new MailValidationError('from is required', 'from')
  }
  if (!msg.subject?.trim()) {
    throw new MailValidationError('subject is required', 'subject')
  }
  if (!msg.html?.trim() && !msg.text?.trim()) {
    throw new MailValidationError('html or text is required', 'html')
  }

  validateAddressField(msg.from, 'from')
  validateAddressField(msg.to, 'to')
  validateAddressField(msg.replyTo, 'replyTo')
  validateAddressField(msg.cc, 'cc')
  validateAddressField(msg.bcc, 'bcc')
}

export type CreateMailOpts = {
  /**
   * Invoked around adapter.send — host decides retry policy for transient failures.
   *
   * FOOTGUN: a retry against a provider with no native idempotency (postmark/ses)
   * can double-send. `idempotencyKey` only protects providers that support it
   * (resend/brevo). For the rest, dedup the send-job one layer up via jobs'
   * IdempotencyStore at dispatch — mail does not own cross-provider exactly-once.
   */
  retry?: (attempt: () => Promise<MailResult>) => Promise<MailResult>
}

export function createMail(adapter: MailAdapter, opts?: CreateMailOpts) {
  return {
    async send(msg: MailMessage): Promise<MailResult> {
      validateMessage(msg)
      const attempt = () => adapter.send(msg)
      if (opts?.retry) {
        return opts.retry(attempt)
      }
      return attempt()
    },
  }
}

/** Zero-dep `{{key}}` interpolation — missing keys become empty strings. */
export function render(tpl: string, data: Record<string, string>): string {
  return tpl.replace(/\{\{(\w+)\}\}/g, (_match, key: string) => data[key] ?? '')
}
