// Stalwart calls this endpoint whenever an event in its webhook subscription
// list fires (auth failures, account creation, policy rejections, DKIM
// failures, etc.). We don't authenticate via JWT here — Stalwart can't carry
// one — instead each request is HMAC-SHA-256 signed with a secret shared via
// the STALWART_WEBHOOK_SECRET env var. Bad signatures get a 401 and a debug
// log line; valid ones are mapped and recorded into the audit collection.
//
// The handler is intentionally permissive about the body shape: Stalwart's
// webhook payload schema has shifted between minor versions, so we read the
// raw body (for HMAC), parse JSON loosely, walk anything that looks like a
// list of events, and let unknown event types fall through to the
// `stalwart.<type>` catch-all in the action map. Worst case: we record raw
// events with a generic verb instead of dropping them.

import {
  Body,
  Controller,
  Headers,
  Logger,
  Post,
  Req,
  UnauthorizedException,
} from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { createHmac, timingSafeEqual } from 'node:crypto'
import { clientIp } from '../auth/client-ip.js'
import { AuditService } from '../audit/audit.service.js'
import { mapStalwartAction } from './action-map.js'

interface StalwartEvent {
  // Stalwart sends `id` (uuid), `createdAt` (ISO), `type` (string), and
  // a `data` blob whose shape depends on `type`. We don't bind tightly to it
  // — anything we use is destructured defensively below.
  id?: string
  createdAt?: string
  type?: string
  data?: Record<string, unknown>
}

interface StalwartWebhookBody {
  events?: StalwartEvent[]
}

@Controller('ingest/stalwart')
export class StalwartWebhookController {
  private readonly logger = new Logger(StalwartWebhookController.name)
  private readonly secret: string

  constructor(
    private readonly audit: AuditService,
    config: ConfigService,
  ) {
    this.secret = config.getOrThrow<string>('STALWART_WEBHOOK_SECRET')
  }

  @Post('webhook')
  async receive(
    @Body() body: StalwartWebhookBody,
    @Headers('x-signature') signature: string | undefined,
    @Req() req: Parameters<typeof clientIp>[0] & { rawBody?: Buffer | string },
  ) {
    // Stalwart sends `X-Signature: <hex-hmac-sha256>` on every webhook. If the
    // body parser already consumed the stream, fall back to JSON-stringifying
    // — Stalwart computes HMAC over the JSON it serializes, which matches
    // what Fastify reconstructs from `body`.
    const raw =
      req.rawBody instanceof Buffer
        ? req.rawBody.toString('utf8')
        : typeof req.rawBody === 'string'
          ? req.rawBody
          : JSON.stringify(body ?? {})
    if (!this.verifySignature(raw, signature)) {
      this.logger.warn(`stalwart webhook: bad signature from ${clientIp(req)}`)
      throw new UnauthorizedException('Invalid webhook signature')
    }

    const events = Array.isArray(body?.events) ? body.events : []
    if (!events.length) {
      this.logger.debug('stalwart webhook: payload had no events')
      return { received: 0 }
    }

    const senderIp = clientIp(req)
    let written = 0
    for (const evt of events) {
      if (!evt.type) continue
      await this.recordOne(evt, senderIp)
      written++
    }
    this.logger.log(`stalwart webhook: received ${written} event(s)`)
    return { received: written }
  }

  private verifySignature(rawBody: string, header: string | undefined): boolean {
    if (!header) return false
    // Strip a `sha256=` prefix if Stalwart includes one (matches GitHub's
    // convention some setups copy).
    const provided = header.replace(/^sha256=/i, '').trim()
    const expected = createHmac('sha256', this.secret).update(rawBody).digest('hex')
    if (provided.length !== expected.length) return false
    return timingSafeEqual(Buffer.from(provided, 'hex'), Buffer.from(expected, 'hex'))
  }

  private async recordOne(evt: StalwartEvent, senderIp: string | undefined): Promise<void> {
    const mapped = mapStalwartAction(evt.type ?? 'unknown')
    const data = evt.data ?? {}
    const at = evt.createdAt ? new Date(evt.createdAt) : new Date()

    // Common Stalwart data fields — present on most categories, missing on
    // others. Pull what we can; let the rest land in metadata.
    const actorEmail = pickString(data, ['email', 'username', 'account', 'remote-email', 'from'])
    const tenantSlug = pickString(data, ['tenant', 'domain'])
    const targetName = pickString(data, ['account', 'email', 'address', 'to', 'message-id'])
    const ip = pickString(data, ['ip', 'remote-ip', 'source-ip']) ?? senderIp

    await this.audit.record(
      {
        action: mapped.action,
        outcome: mapped.outcome,
        resourceType: mapped.resourceType,
        resourceName: targetName ?? evt.type,
        tenantSlug,
        source: 'stalwart',
        externalId: evt.id,
        at,
        metadata: { rawType: evt.type, data },
      },
      { email: actorEmail, ip },
    )
  }
}

function pickString(obj: Record<string, unknown>, keys: string[]): string | undefined {
  for (const k of keys) {
    const v = obj[k]
    if (typeof v === 'string' && v.length) return v
  }
  return undefined
}
