// Phase 5 — Payment provider webhooks.
//
// Endpoint: POST /api/shared/payment/webhooks/xendit
//
// Public route (Xendit cannot present a JWT) authenticated by the
// `x-callback-token` header verified against env XENDIT_WEBHOOK_TOKEN.
//
// Body shape from Xendit Invoice callback (relevant fields only):
//   { id, external_id, status, amount, payment_method, paid_at, metadata, ... }
//
// Handled statuses: PAID (→ confirmPayment), EXPIRED (→ expirePayment).
// Anything else ACKs with `{ ok: true, ignored: <status> }` for forward-compat.
//
// SURVIVAL LOGGING — every inbound webhook lands in `webhook_logs` BEFORE the
// auth check or any business logic, so we keep a forensic record even when:
//   • the token is wrong (401)
//   • the body is malformed / missing external_id
//   • the referenced payment_request doesn't exist
//   • amount mismatches and we reject (409)
//   • downstream processing throws (500)
//
// If the DB insert itself fails, an optional rolling-file sink can absorb the
// event (see WEBHOOK_FALLBACK_* env vars in webhook-log.service.js). The sink
// is disabled by default — production opts in by mounting a persistent volume
// and flipping WEBHOOK_FALLBACK_ENABLED=true. We deliberately do NOT fall
// back to stdout; operators decide if/where survival writes happen.
//
// CONTROL FLOW NOTE — branches set `result` + `httpStatus` + `responseBody`
// into closure vars rather than calling `reply.send()` directly. Fastify
// flushes the response on `reply.send()` and `app.inject` resolves before any
// post-send finally would run, which would lose the outcome columns. We
// instead update the log once at the end, then send the reply once.

import { confirmPayment, expirePayment, getPayment, verifyWebhookToken } from '../../services/payment.service.js'
import {
  insertWebhookLog,
  sanitizeHeaders,
  updateWebhookLog,
  writeWebhookFallback,
} from '../../services/webhook-log.service.js'

// Our payment_requests.id is a UUID. A non-UUID external_id is either a legacy
// old-app invoice (should have been filtered by the router) or a stray Xendit
// event for a product we don't model. Skip the DB lookup (it would throw on
// the cast) and ACK so Xendit stops retrying.
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i

export const paymentWebhookRoutes = async (app) => {
  app.post('/webhooks/xendit', async (request, reply) => {
    const body = request.body ?? {}
    const headerToken = request.headers['x-callback-token']
    const tokenValid = verifyWebhookToken(headerToken)
    const sanitizedHeaders = sanitizeHeaders(request.headers)

    // --- STEP 1: survival log ----------------------------------------------
    // Insert before doing anything else. If this throws, attempt the optional
    // rolling-file sink (env-gated, off by default). If that ALSO fails or is
    // disabled, we silently continue — no stdout fallback by design, the
    // operator's env config decides where survival writes go.
    let logId = null
    try {
      logId = await insertWebhookLog({
        provider: 'xendit',
        headers: sanitizedHeaders,
        rawBody: body,
        callbackTokenValid: tokenValid,
      })
    } catch (dbErr) {
      try {
        await writeWebhookFallback({
          provider: 'xendit',
          headers: sanitizedHeaders,
          rawBody: body,
          callbackTokenValid: tokenValid,
          dbErrorMessage: dbErr?.message,
        })
      } catch {
        // Both sinks down. Nothing left to try — proceed without an audit
        // trail. The operator chose this configuration; failure here is
        // their disk/permission/config issue to monitor externally.
      }
    }

    // --- STEP 2: process ---------------------------------------------------
    // Defaults match the "uncaught exception in processing" case — finally
    // (well, the post-try outcome stamp below) will stamp these into the log.
    let result = 'error'
    let httpStatus = 500
    let responseBody = { error: 'internal_error' }
    let errorMsg = null

    try {
      if (!tokenValid) {
        request.log.warn('xendit webhook: bad token')
        result = 'rejected_invalid_token'
        httpStatus = 401
        responseBody = { error: 'invalid_token' }
      } else {
        const invoiceId = body.id
        const paymentRequestId = body.external_id
        const status = body.status
        const amount = typeof body.amount === 'number' ? body.amount : null
        const paymentMethod = body.payment_method ?? null

        request.log.info(
          { paymentRequestId, invoiceId, status, amount, paymentMethod },
          'xendit webhook received',
        )

        if (!paymentRequestId) {
          // Forward-compat: future Xendit event types may not carry external_id
          result = 'ignored_no_external_id'
          httpStatus = 200
          responseBody = { ok: true, ignored: 'no_external_id' }
        } else if (!UUID_RE.test(paymentRequestId)) {
          // Not one of ours — see UUID_RE comment above.
          request.log.warn({ paymentRequestId, invoiceId }, 'non-UUID external_id — ACKing')
          result = 'ignored_non_uuid_external_id'
          httpStatus = 200
          responseBody = { ok: true, ignored: 'non_uuid_external_id' }
        } else {
          const existing = await getPayment(paymentRequestId)
          if (!existing) {
            // Unknown payment — could be stale orphan from a wiped dev DB. ACK so Xendit
            // stops retrying; warn so we notice if this becomes common in prod.
            request.log.warn({ paymentRequestId, invoiceId }, 'unknown payment_request — ACKing')
            result = 'ignored_unknown_payment_request'
            httpStatus = 200
            responseBody = { ok: true, ignored: 'unknown_payment_request' }
          } else if (status === 'PAID') {
            // Defensive: amount mismatch = either tampering or config drift. Refuse to confirm.
            if (amount !== null && amount !== existing.amount) {
              request.log.error(
                { paymentRequestId, expected: existing.amount, got: amount },
                'xendit webhook: amount mismatch',
              )
              result = 'rejected_amount_mismatch'
              httpStatus = 409
              responseBody = { error: 'amount_mismatch' }
            } else {
              try {
                await confirmPayment(paymentRequestId, { invoiceId, paymentMethod, amount })
                result = 'confirmed'
              } catch (err) {
                // INVALID_STATE = already confirmed/consumed (Xendit retry); CONFLICT = race lost. ACK.
                // EXPIRED = customer paid AFTER our sweeper expired the row — painful, manual
                //          recovery needed. Log loud so we notice. (D5 alignment should keep
                //          this rare.)
                if (err.code === 'INVALID_STATE' || err.code === 'CONFLICT') {
                  request.log.info(
                    { paymentRequestId, code: err.code, prevStatus: existing.status },
                    'xendit webhook: already in terminal state, ACKing',
                  )
                  result = `idempotent_${err.code.toLowerCase()}`
                } else if (err.code === 'EXPIRED') {
                  request.log.error(
                    { paymentRequestId, expiredAt: existing.expires_at },
                    'xendit webhook: PAID after expiry — manual recovery needed',
                  )
                  result = 'paid_after_expiry'
                } else {
                  throw err
                }
              }
              httpStatus = 200
              responseBody = { ok: true }
            }
          } else if (status === 'EXPIRED') {
            await expirePayment(paymentRequestId)
            result = 'expired'
            httpStatus = 200
            responseBody = { ok: true }
          } else {
            result = `ignored_${status}`
            httpStatus = 200
            responseBody = { ok: true, ignored: status }
          }
        }
      }
    } catch (err) {
      // Anything that bubbles out of the processing block lands here. We do NOT
      // re-throw — instead we ACK to ourselves (logged loudly) and return 500
      // so Xendit retries on its own schedule. The forensic row records why.
      errorMsg = err?.message ?? String(err)
      request.log.error(
        { err: errorMsg, paymentRequestId: body.external_id },
        'xendit webhook: unhandled exception during processing',
      )
      // Defaults already at result='error', httpStatus=500, responseBody=error
    }

    // --- STEP 3: outcome stamp + reply -------------------------------------
    // Awaited so the log row is consistent before we hand control back to the
    // framework. Wrapped in its own try/catch: a failed update must never
    // prevent us from responding to Xendit.
    if (logId) {
      try {
        await updateWebhookLog(logId, {
          httpStatus,
          processingResult: result,
          processingError: errorMsg,
        })
      } catch (updErr) {
        request.log.error({ err: updErr?.message, logId }, 'webhook_log update failed')
      }
    }

    return reply.code(httpStatus).send(responseBody)
  })
}
