/**
 * Webhook delivery service.
 *
 * The backend persists the terminal event in WebhookOutbox before it performs
 * network I/O. This module deliberately keeps the outbox payload separate from
 * callback credentials: the secret is supplied to the sender at delivery time
 * and is used only as the HMAC key.
 */

import crypto from 'crypto';
import axios, { AxiosError } from 'axios';
import { PrismaClient, TranslationJobStatus } from '@prisma/client';
import { WebhookPayload, WebhookDelivery } from '../types';
import { logger } from '../utils/logger';
import { config } from '../config';
import { mapStoredTranslation } from './translationService';
import { trackWebhookDelivery } from '../utils/metrics';

const prisma = new PrismaClient();

const WEBHOOK_REQUEST_TIMEOUT_MS = 10_000;
const DEFAULT_MAX_ATTEMPTS = 5;
const MAX_CONFIGURED_ATTEMPTS = 20;
const DEFAULT_RETRY_BASE_DELAY_MS = 5_000;
const MAX_RETRY_DELAY_MS = 60_000;
const DEFAULT_OUTBOX_LEASE_MS = 60_000;
const DEFAULT_OUTBOX_BATCH_SIZE = 100;
const MAX_OUTBOX_BATCH_SIZE = 500;
const MAX_ERROR_LENGTH = 512;
const MAX_CALLBACK_URL_LENGTH = 2048;
const MAX_IDENTITY_BYTES = 512;
const MAX_WEBHOOK_PAYLOAD_BYTES = 4 * 1024 * 1024;
const MAX_PAYLOAD_DEPTH = 32;
const MAX_PAYLOAD_PROPERTIES = 20_000;
const MAX_PAYLOAD_ARRAY_ITEMS = 2_000;
const MAX_PAYLOAD_STRING_BYTES = MAX_WEBHOOK_PAYLOAD_BYTES;

const TERMINAL_WEBHOOK_EVENTS = new Set([
  'translation.completed',
  'translation.failed',
  'bulk_translation.completed',
  'bulk_translation.failed',
  'bulk_content_translation.completed',
  'bulk_content_translation.failed',
]);

/** Values that must never be copied into a persisted callback payload. */
const SENSITIVE_PAYLOAD_KEYS = new Set([
  'authorization',
  'authorizationheader',
  'callbacksecret',
  'callback_secret',
  'callbackurl',
  'callback_url',
  'apikey',
  'api_key',
  'sourcecontent',
  'source_content',
  'sourcefields',
  'source_fields',
  'originalcontent',
  'original_content',
]);

/**
 * The old `WebhookDelivery` model is retained as an append-only attempt
 * history. These fields are intentionally additive so existing worker callers
 * can continue to use `success`, `errorMessage`, and `httpStatus`.
 */
export type WebhookDeliveryStatus = 'delivered' | 'retry_wait' | 'dead';

export interface WebhookRetryMetadata {
  status: WebhookDeliveryStatus;
  attemptNumber: number;
  maxAttempts: number;
  retryable: boolean;
  nextAttemptAt?: Date;
  responseStatus?: number;
}

export type WebhookDeliveryResult = WebhookDelivery & WebhookRetryMetadata & {
  /** Stable callback identity, normally the WebhookOutbox primary key. */
  deliveryId: string;
  /** Monotonic per-job event identity when an outbox row supplied one. */
  eventSequence?: number;
};

export interface WebhookDeliveryOptions {
  /** Stable delivery identity. It must not change between retry attempts. */
  stableDeliveryId?: string;
  /** Attempt number for durable scheduling. Defaults to one. */
  attemptNumber?: number;
  /** Maximum automatic attempts. Clamped to a small bounded range. */
  maxAttempts?: number;
  /** Timestamp used for the signed request. Defaults to the current clock. */
  now?: Date | number;
  /** Event sequence echoed in the callback body. */
  eventSequence?: number;
  /** Submission identity echoed in the callback body. */
  submissionId?: string;
  /** Exact previously persisted request bytes. No reserialization is done. */
  canonicalBody?: string;
  /** Base delay used to calculate the next retry due time. */
  retryBaseDelayMs?: number;
  /** Upper bound for exponential retry delay. */
  retryMaxDelayMs?: number;
  /** Additional bounded jitter in milliseconds. Defaults to 25% of the delay. */
  jitterMs?: number;
  /** Injectable random source for deterministic scheduler tests. */
  random?: () => number;
}

export interface WebhookOutboxCreateInput {
  jobId: string;
  event: string;
  payload: WebhookPayload | Record<string, unknown>;
  deliveryId?: string;
  eventSequence?: number;
  /** Alias accepted for callers that use the database column name. */
  deliverySequence?: number;
  submissionId?: string;
  clientJobId?: string;
  refs?: readonly string[];
  results?: unknown;
  failure?: unknown;
  isFinal?: boolean;
  now?: Date;
}

export interface WebhookOutboxClaimOptions {
  now?: Date;
  leaseMs?: number;
  maxAttempts?: number;
  batchSize?: number;
}

export interface WebhookOutboxEntry {
  id: string;
  job_id: string;
  event: string;
  delivery_sequence: number;
  payload: unknown;
  status?: string;
  attempts: number;
  next_attempt_at?: Date | null;
  first_attempt_at?: Date | null;
  last_attempt_at?: Date | null;
  dead_at?: Date | null;
  response_status?: number | null;
  claimed_at?: Date | null;
  claimedAt?: Date;
  attemptNumber?: number;
  job?: {
    callback_url: string | null;
    callback_secret: string | null;
  };
}

export interface WebhookOutboxSettlement {
  entryId: string;
  claimedAt: Date;
  result: Pick<
    WebhookDeliveryResult,
    'success' | 'status' | 'errorMessage' | 'httpStatus' | 'nextAttemptAt'
  >;
  now?: Date;
}

type JsonRecord = Record<string, unknown>;

type OutboxDelegate = {
  findFirst(args: object): Promise<unknown>;
  findMany(args: object): Promise<unknown[]>;
  create(args: object): Promise<unknown>;
  updateMany(args: object): Promise<{ count: number }>;
};

type OutboxDatabase = {
  webhookOutbox: OutboxDelegate;
};

/**
 * Preserve the structured result mapper used by legacy and bulk callbacks.
 */
export function getStructuredWebhookResult(rawTranslation: string | null) {
  return mapStoredTranslation(rawTranslation);
}

function asOutboxDatabase(database: unknown): OutboxDatabase {
  return database as OutboxDatabase;
}

function asRecord(value: unknown): JsonRecord | null {
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
    return null;
  }
  return value as JsonRecord;
}

type PayloadContext = 'envelope' | 'result_item' | 'result_fields' | 'failure';

const ROOT_SOURCE_KEYS = new Set(['content', 'title', 'excerpt', 'source']);
const RESULT_CONTAINER_KEYS = new Set([
  'result',
  'results',
  'translatedfields',
]);

function normalizedPayloadKey(key: string): string {
  return key.replace(/[-_\s]/g, '').toLowerCase();
}

function isSensitivePayloadKey(key: string, context: PayloadContext): boolean {
  if (context === 'result_fields') {
    return false;
  }

  const normalized = normalizedPayloadKey(key);
  if (SENSITIVE_PAYLOAD_KEYS.has(key) || SENSITIVE_PAYLOAD_KEYS.has(normalized)) {
    return true;
  }

  return ROOT_SOURCE_KEYS.has(normalized);
}

function childPayloadContext(context: PayloadContext, key: string): PayloadContext {
  if (context === 'result_fields') {
    return 'result_fields';
  }
  if (context === 'result_item') {
    return normalizedPayloadKey(key) === 'fields' || normalizedPayloadKey(key) === 'translatedfields'
      ? 'result_fields'
      : 'result_item';
  }

  const normalized = normalizedPayloadKey(key);
  if (context === 'envelope' && normalized === 'translatedfields') {
    return 'result_fields';
  }
  if (context === 'envelope' && RESULT_CONTAINER_KEYS.has(normalized)) {
    return 'result_item';
  }
  if (context === 'envelope' && normalized === 'failure') {
    return 'failure';
  }
  return context;
}

function sanitizePayloadValue(
  value: unknown,
  context: PayloadContext = 'envelope',
  ancestors: WeakSet<object> = new WeakSet()
): unknown {
  if (Array.isArray(value)) {
    if (ancestors.has(value)) {
      throw new Error('Webhook payload must not contain cyclic values');
    }
    ancestors.add(value);
    const result = value.map((item) => sanitizePayloadValue(item, context, ancestors));
    ancestors.delete(value);
    return result;
  }

  const record = asRecord(value);
  if (!record) {
    return value;
  }
  if (ancestors.has(record)) {
    throw new Error('Webhook payload must not contain cyclic values');
  }

  // Translation fields are deliberately arbitrary in name, but their values
  // are always strings. Rejecting nested values here prevents a field named
  // "authorization" from becoming a nested credential escape hatch.
  if (context === 'result_fields' && Object.values(record).some((child) => typeof child !== 'string')) {
    throw new Error('Webhook result fields must be string maps');
  }

  ancestors.add(record);
  const result: JsonRecord = {};
  for (const [key, child] of Object.entries(record)) {
    if (isSensitivePayloadKey(key, context)) {
      continue;
    }
    result[key] = sanitizePayloadValue(child, childPayloadContext(context, key), ancestors);
  }
  ancestors.delete(record);
  return result;
}

interface PayloadBounds {
  properties: number;
  arrayItems: number;
}

function assertPayloadBounds(
  value: unknown,
  context: PayloadContext = 'envelope',
  depth = 0,
  bounds: PayloadBounds = { properties: 0, arrayItems: 0 }
): void {
  if (depth > MAX_PAYLOAD_DEPTH) {
    throw new Error('Webhook payload exceeds the maximum nesting depth');
  }

  if (typeof value === 'string') {
    if (Buffer.byteLength(value, 'utf8') > MAX_PAYLOAD_STRING_BYTES) {
      throw new Error('Webhook payload contains an oversized string');
    }
    return;
  }

  if (Array.isArray(value)) {
    bounds.arrayItems += value.length;
    if (value.length > MAX_PAYLOAD_ARRAY_ITEMS || bounds.arrayItems > MAX_PAYLOAD_ARRAY_ITEMS) {
      throw new Error('Webhook payload contains too many array items');
    }
    for (const item of value) {
      assertPayloadBounds(item, context, depth + 1, bounds);
    }
    return;
  }

  const record = asRecord(value);
  if (!record) {
    return;
  }

  bounds.properties += Object.keys(record).length;
  if (bounds.properties > MAX_PAYLOAD_PROPERTIES) {
    throw new Error('Webhook payload contains too many properties');
  }
  for (const [key, child] of Object.entries(record)) {
    assertPayloadBounds(child, childPayloadContext(context, key), depth + 1, bounds);
  }
}

function sanitizeWebhookPayload(payload: unknown): JsonRecord {
  const sanitized = sanitizePayloadValue(payload, 'envelope');
  const record = asRecord(sanitized);
  if (!record) {
    throw new Error('Webhook payload must be a JSON object');
  }
  assertPayloadBounds(record);
  return record;
}

function stringifyWebhookPayload(payload: JsonRecord): string {
  let body: string | undefined;
  try {
    body = JSON.stringify(payload);
  } catch {
    throw new Error('Webhook payload could not be serialized');
  }
  if (body === undefined) {
    throw new Error('Webhook payload could not be serialized');
  }
  if (Buffer.byteLength(body, 'utf8') > MAX_WEBHOOK_PAYLOAD_BYTES) {
    throw new Error('Webhook payload exceeds the maximum size');
  }
  return body;
}

function containsSensitivePayloadKey(value: unknown, context: PayloadContext = 'envelope'): boolean {
  if (context === 'result_fields') {
    return false;
  }

  if (Array.isArray(value)) {
    return value.some((item) => containsSensitivePayloadKey(item, context));
  }

  const record = asRecord(value);
  if (!record) {
    return false;
  }

  return Object.entries(record).some(([key, child]) =>
    isSensitivePayloadKey(key, context)
      || containsSensitivePayloadKey(child, childPayloadContext(context, key))
  );
}

function parseSecretFreeCanonicalBody(body: string): JsonRecord {
  if (typeof body !== 'string') {
    throw new Error('Canonical webhook body must be a string');
  }
  if (Buffer.byteLength(body, 'utf8') > MAX_WEBHOOK_PAYLOAD_BYTES) {
    throw new Error('Canonical webhook body exceeds the maximum size');
  }

  let parsed: unknown;
  try {
    parsed = JSON.parse(body);
  } catch {
    throw new Error('Webhook payload is not valid JSON');
  }

  assertPayloadBounds(parsed);
  if (containsSensitivePayloadKey(parsed)) {
    throw new Error('Webhook payload contains restricted fields');
  }

  const record = asRecord(parsed);
  if (!record) {
    throw new Error('Webhook payload must be a JSON object');
  }
  return record;
}

function boundedInteger(value: number | undefined, fallback: number, min: number, max: number): number {
  if (!Number.isSafeInteger(value) || value === undefined) {
    return fallback;
  }
  return Math.min(Math.max(value, min), max);
}

function boundedAttempts(value: number | undefined): number {
  return boundedInteger(value, DEFAULT_MAX_ATTEMPTS, 1, MAX_CONFIGURED_ATTEMPTS);
}

function boundedBaseDelay(value: number | undefined): number {
  if (!Number.isFinite(value) || value === undefined || value < 0) {
    return DEFAULT_RETRY_BASE_DELAY_MS;
  }
  return Math.min(Math.floor(value), MAX_RETRY_DELAY_MS);
}

function boundedError(value: unknown): string | undefined {
  if (value === undefined || value === null) {
    return undefined;
  }

  const text = value instanceof Error ? value.message : String(value);
  const sanitized = text
    .replace(/[\x00-\x1F\x7F]/g, ' ')
    .replace(/(?:authorization|x-ipz-callback-secret|callback[_-]?secret|api[_-]?key)\s*[:=]\s*[^\s,;]+/gi, '[redacted]')
    .replace(/https?:\/\/[^\s)]+/gi, '[url]')
    .trim();

  if (!sanitized) {
    return undefined;
  }
  return sanitized.length > MAX_ERROR_LENGTH
    ? `${sanitized.slice(0, MAX_ERROR_LENGTH - 1)}…`
    : sanitized;
}

function boundedCallbackUrl(value: string): string {
  // URL is used only as an axios destination. It is never sent to the logger.
  if (typeof value !== 'string' || value.length === 0) {
    throw new Error('Webhook callback URL must be a non-empty string');
  }
  if (Buffer.byteLength(value, 'utf8') > MAX_CALLBACK_URL_LENGTH) {
    throw new Error('Webhook callback URL exceeds the maximum length');
  }
  return value;
}

function toDate(value: Date | number | undefined): Date {
  if (value instanceof Date && Number.isFinite(value.getTime())) {
    return new Date(value.getTime());
  }
  if (typeof value === 'number' && Number.isFinite(value)) {
    const date = new Date(value);
    if (Number.isFinite(date.getTime())) {
      return date;
    }
  }
  return new Date(Date.now());
}

function timestampHeader(value: Date | number | undefined): string {
  return String(toDate(value).getTime());
}

function retryableHttpStatus(status: number | undefined): boolean {
  if (status === undefined) {
    return true;
  }
  return status === 408 || status === 425 || status === 429 || status >= 500;
}

function retryDelayMs(
  attemptNumber: number,
  baseDelayMs: number,
  maxDelayMs: number,
  jitterMs: number | undefined,
  random: (() => number) | undefined
): number {
  const configuredMax = Number.isFinite(maxDelayMs) ? maxDelayMs : MAX_RETRY_DELAY_MS;
  const boundedMax = Math.min(Math.max(0, Math.floor(configuredMax)), MAX_RETRY_DELAY_MS);
  const boundedBase = Math.min(Math.max(0, Math.floor(baseDelayMs)), boundedMax);
  const exponent = Math.min(Math.max(attemptNumber - 1, 0), 30);
  const exponential = Math.min(boundedMax, boundedBase * (2 ** exponent));
  const defaultJitter = Math.floor(exponential * 0.25);
  const boundedJitter = boundedInteger(jitterMs, defaultJitter, 0, MAX_RETRY_DELAY_MS - exponential);
  if (boundedJitter === 0) {
    return exponential;
  }

  const randomValue = Math.min(Math.max(random?.() ?? Math.random(), 0), 1);
  return Math.min(boundedMax, exponential + Math.floor(randomValue * (boundedJitter + 1)));
}

function buildRetryMetadata(
  success: boolean,
  httpStatus: number | undefined,
  attemptNumber: number,
  maxAttempts: number,
  now: Date,
  options: WebhookDeliveryOptions
): WebhookRetryMetadata {
  if (success) {
    return {
      status: 'delivered',
      attemptNumber,
      maxAttempts,
      retryable: false,
      responseStatus: httpStatus,
    };
  }

  const retryable = retryableHttpStatus(httpStatus);
  if (!retryable || attemptNumber >= maxAttempts) {
    return {
      status: 'dead',
      attemptNumber,
      maxAttempts,
      retryable: false,
      responseStatus: httpStatus,
    };
  }

  const delay = retryDelayMs(
    attemptNumber,
    boundedBaseDelay(options.retryBaseDelayMs),
    Math.min(options.retryMaxDelayMs ?? MAX_RETRY_DELAY_MS, MAX_RETRY_DELAY_MS),
    options.jitterMs,
    options.random
  );

  return {
    status: 'retry_wait',
    attemptNumber,
    maxAttempts,
    retryable: true,
    nextAttemptAt: new Date(now.getTime() + delay),
    responseStatus: httpStatus,
  };
}

function boundedIdentity(value: unknown, label: string): string {
  if (typeof value !== 'string' || value.trim().length === 0) {
    throw new Error(`Webhook ${label} must be a non-empty string`);
  }
  if (Buffer.byteLength(value, 'utf8') > MAX_IDENTITY_BYTES) {
    throw new Error(`Webhook ${label} exceeds the maximum length`);
  }
  return value;
}

function stringIdentityFromPayload(
  payload: JsonRecord,
  keys: readonly string[],
  label: string
): string | undefined {
  const values = keys
    .map((key) => payload[key])
    .filter((value) => value !== undefined);
  if (values.length === 0) {
    return undefined;
  }

  const identities = values.map((value) => boundedIdentity(value, label));
  if (new Set(identities).size !== 1) {
    throw new Error(`Webhook ${label} aliases do not match`);
  }
  return identities[0];
}

function deliveryIdFromPayload(payload: JsonRecord): string | undefined {
  return stringIdentityFromPayload(payload, ['deliveryId', 'delivery_id'], 'delivery ID');
}

function submissionIdFromPayload(payload: JsonRecord): string | undefined {
  return stringIdentityFromPayload(payload, ['submission_id', 'submissionId'], 'submission ID');
}

function clientJobIdFromPayload(payload: JsonRecord): string | undefined {
  return stringIdentityFromPayload(payload, ['clientJobId', 'client_job_id'], 'client job ID');
}

function eventSequenceFromPayload(payload: JsonRecord): number | undefined {
  const values = ['event_sequence', 'eventSequence']
    .map((key) => payload[key])
    .filter((value) => value !== undefined);
  if (values.length === 0) {
    return undefined;
  }
  if (values.some((value) => !Number.isSafeInteger(value) || (value as number) < 1)) {
    throw new Error('Webhook event sequence must be a positive integer');
  }

  const sequences = values as number[];
  if (new Set(sequences).size !== 1) {
    throw new Error('Webhook event sequence aliases do not match');
  }
  return sequences[0];
}

function payloadValuesEqual(left: unknown, right: unknown): boolean {
  if (Object.is(left, right)) {
    return true;
  }
  if (typeof left !== typeof right || left === null || right === null) {
    return false;
  }

  if (Array.isArray(left) || Array.isArray(right)) {
    if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) {
      return false;
    }
    return left.every((value, index) => payloadValuesEqual(value, right[index]));
  }

  const leftRecord = asRecord(left);
  const rightRecord = asRecord(right);
  if (!leftRecord || !rightRecord) {
    return false;
  }
  const leftKeys = Object.keys(leftRecord).sort();
  const rightKeys = Object.keys(rightRecord).sort();
  if (leftKeys.length !== rightKeys.length || leftKeys.some((key, index) => key !== rightKeys[index])) {
    return false;
  }
  return leftKeys.every((key) => payloadValuesEqual(leftRecord[key], rightRecord[key]));
}

function requiredIdentity(
  inputValue: string | undefined,
  payloadValue: string | undefined,
  label: string
): string {
  if (inputValue !== undefined) {
    const identity = boundedIdentity(inputValue, label);
    if (payloadValue !== undefined && identity !== payloadValue) {
      throw new Error(`Webhook ${label} does not match payload identity`);
    }
    return identity;
  }
  if (payloadValue !== undefined) {
    return payloadValue;
  }
  throw new Error(`Terminal webhook is missing ${label}`);
}

function normalizeRefs(value: unknown): string[] {
  if (!Array.isArray(value) || value.length === 0) {
    throw new Error('Terminal webhook refs must be a non-empty array');
  }
  const refs = value.map((ref) => {
    if (typeof ref !== 'string' || ref.trim().length === 0) {
      throw new Error('Terminal webhook refs must contain non-empty strings');
    }
    if (Buffer.byteLength(ref, 'utf8') > MAX_IDENTITY_BYTES) {
      throw new Error('Terminal webhook ref exceeds the maximum length');
    }
    return ref;
  });
  if (new Set(refs).size !== refs.length) {
    throw new Error('Terminal webhook refs must be unique');
  }
  return refs;
}

function validateBulkResults(value: unknown): string[] {
  if (!Array.isArray(value) || value.length === 0) {
    throw new Error('Bulk terminal webhook results must be a non-empty array');
  }

  const refs = value.map((item) => {
    const result = asRecord(item);
    if (!result || typeof result.ref !== 'string' || result.ref.trim().length === 0) {
      throw new Error('Bulk terminal webhook results require a ref');
    }
    if (Buffer.byteLength(result.ref, 'utf8') > MAX_IDENTITY_BYTES) {
      throw new Error('Bulk terminal webhook result ref exceeds the maximum length');
    }
    if (result.status !== 'completed' && result.status !== 'failed') {
      throw new Error('Bulk terminal webhook results require a terminal status');
    }
    if (result.status === 'completed' && result.fields === undefined) {
      throw new Error('Completed bulk terminal webhook results require fields');
    }
    if (result.fields !== undefined) {
      const fields = asRecord(result.fields);
      if (!fields || Object.values(fields).some((field) => typeof field !== 'string')) {
        throw new Error('Bulk terminal webhook result fields must be string maps');
      }
    }
    return result.ref;
  });

  if (new Set(refs).size !== refs.length) {
    throw new Error('Bulk terminal webhook result refs must be unique');
  }
  return refs;
}

function legacyBulkResultRefs(value: unknown): string[] {
  const container = asRecord(value);
  if (!container) {
    return [];
  }

  const refs: string[] = [];
  for (const languageResults of Object.values(container)) {
    if (!Array.isArray(languageResults)) {
      throw new Error('Legacy bulk webhook results must be arrays');
    }
    for (const item of languageResults) {
      const result = asRecord(item);
      if (!result || typeof result.id !== 'string' || result.id.trim().length === 0) {
        throw new Error('Legacy bulk webhook results require an id');
      }
      refs.push(result.id);
    }
  }
  return refs;
}

function sanitizeFailure(value: unknown): JsonRecord {
  const sanitized = sanitizePayloadValue(value, 'failure');
  const failure = asRecord(sanitized);
  if (!failure || typeof failure.code !== 'string' || failure.code.trim().length === 0) {
    throw new Error('Failed terminal webhook requires a failure code');
  }
  if (failure.message !== undefined && typeof failure.message !== 'string') {
    throw new Error('Terminal webhook failure message must be a string');
  }
  return {
    ...failure,
    code: boundedError(failure.code) ?? 'translation_failed',
    ...(typeof failure.message === 'string'
      ? { message: boundedError(failure.message) ?? 'Translation failed' }
      : {}),
  };
}

function validateTerminalWebhookPayload(
  input: WebhookOutboxCreateInput,
  rawPayload: JsonRecord
): void {
  if (!input.jobId || typeof input.jobId !== 'string') {
    throw new Error('Terminal webhook requires a job ID');
  }
  boundedIdentity(input.jobId, 'job ID');

  if (rawPayload.event !== undefined && typeof rawPayload.event !== 'string') {
    throw new Error('Terminal webhook event must be a string');
  }
  const payloadEvent = typeof rawPayload.event === 'string' ? rawPayload.event : undefined;
  if (payloadEvent !== undefined && payloadEvent !== input.event) {
    throw new Error('Terminal webhook event does not match payload event');
  }
  rawPayload.event = input.event;

  const payloadJobId = stringIdentityFromPayload(rawPayload, ['jobId', 'job_id'], 'job ID');
  if (payloadJobId !== undefined && payloadJobId !== input.jobId) {
    throw new Error('Terminal webhook job ID does not match payload identity');
  }
  rawPayload.jobId = input.jobId;
  rawPayload.job_id = input.jobId;

  const isBulkContent = input.event.startsWith('bulk_content_translation.');
  const isLegacyBulk = input.event.startsWith('bulk_translation.');
  const isBulk = isBulkContent || isLegacyBulk;

  const payloadSubmissionId = submissionIdFromPayload(rawPayload);
  if (isBulkContent) {
    rawPayload.submission_id = requiredIdentity(
      input.submissionId,
      payloadSubmissionId,
      'submission ID'
    );
  } else if (input.submissionId !== undefined || payloadSubmissionId !== undefined) {
    rawPayload.submission_id = requiredIdentity(
      input.submissionId,
      payloadSubmissionId,
      'submission ID'
    );
  }

  const payloadClientJobId = clientJobIdFromPayload(rawPayload);
  if (isBulkContent) {
    rawPayload.clientJobId = requiredIdentity(
      input.clientJobId,
      payloadClientJobId,
      'client job ID'
    );
  } else if (input.clientJobId !== undefined || payloadClientJobId !== undefined) {
    rawPayload.clientJobId = requiredIdentity(
      input.clientJobId,
      payloadClientJobId,
      'client job ID'
    );
  }

  if (input.results !== undefined) {
    rawPayload.results = sanitizePayloadValue(input.results, 'result_item');
  }
  if (input.failure !== undefined) {
    rawPayload.failure = sanitizePayloadValue(input.failure, 'failure');
  }

  const isFailure = input.event.endsWith('.failed');
  const expectedStatus = isFailure ? 'failed' : 'completed';
  if (rawPayload.status !== undefined && rawPayload.status !== expectedStatus) {
    throw new Error('Terminal webhook status does not match event');
  }
  rawPayload.status = expectedStatus;

  const resultValue = rawPayload.results;
  const resultRefs = resultValue === undefined
    ? []
    : isBulkContent
      ? validateBulkResults(resultValue)
      : [];
  if (isBulkContent && !isFailure && resultValue === undefined) {
    throw new Error('Completed bulk terminal webhook requires results');
  }

  if (isFailure) {
    if (rawPayload.failure === undefined && typeof rawPayload.errorMessage === 'string') {
      rawPayload.failure = {
        code: 'translation_failed',
        message: rawPayload.errorMessage,
      };
    }
    rawPayload.failure = sanitizeFailure(rawPayload.failure);
  }

  const payloadRefs = rawPayload.refs === undefined ? undefined : normalizeRefs(rawPayload.refs);
  const singleRef = typeof rawPayload.ref === 'string' && rawPayload.ref.trim().length > 0
    ? [rawPayload.ref]
    : undefined;
  const legacyRefs = isLegacyBulk
    ? legacyBulkResultRefs(rawPayload.results_by_lang ?? rawPayload.resultsByLang)
    : [];
  const refs = payloadRefs
    ?? singleRef
    ?? (resultRefs.length > 0 ? resultRefs : undefined)
    ?? (legacyRefs.length > 0 ? normalizeRefs(legacyRefs) : undefined);
  if (!refs) {
    throw new Error('Terminal webhook is missing refs');
  }
  if (resultRefs.some((ref) => !refs.includes(ref))) {
    throw new Error('Terminal webhook result ref is not in refs');
  }
  if (isBulkContent && !isFailure) {
    const refSet = new Set(refs);
    const resultRefSet = new Set(resultRefs);
    if (refSet.size !== resultRefSet.size || [...refSet].some((ref) => !resultRefSet.has(ref))) {
      throw new Error('Completed bulk terminal webhook refs must match result refs');
    }
  }
  if (!isBulk && refs.length !== 1) {
    throw new Error('Single terminal webhook must contain exactly one ref');
  }
  rawPayload.refs = refs;
}

function preparePayload(
  payload: unknown,
  stableDeliveryId: string,
  options: WebhookDeliveryOptions
): JsonRecord {
  const result = sanitizeWebhookPayload(payload);
  const payloadDeliveryId = deliveryIdFromPayload(result);
  boundedIdentity(stableDeliveryId, 'delivery ID');
  if (stableDeliveryId !== undefined
    && payloadDeliveryId !== undefined
    && stableDeliveryId !== payloadDeliveryId) {
    throw new Error('Webhook delivery ID does not match payload identity');
  }
  // The outbox identity is authoritative. A retry may never mint a new body ID.
  result.deliveryId = stableDeliveryId || payloadDeliveryId || crypto.randomUUID();

  const payloadSubmissionId = submissionIdFromPayload(result);
  if (options.submissionId !== undefined) {
    result.submission_id = requiredIdentity(options.submissionId, payloadSubmissionId, 'submission ID');
  } else if (payloadSubmissionId !== undefined && result.submission_id === undefined) {
    result.submission_id = payloadSubmissionId;
  }

  const payloadEventSequence = eventSequenceFromPayload(result);
  if (options.eventSequence !== undefined) {
    if (!Number.isSafeInteger(options.eventSequence) || options.eventSequence < 1) {
      throw new Error('Webhook event sequence must be a positive integer');
    }
    if (payloadEventSequence !== undefined && payloadEventSequence !== options.eventSequence) {
      throw new Error('Webhook event sequence does not match payload identity');
    }
    result.event_sequence = options.eventSequence;
  } else if (payloadEventSequence !== undefined && result.event_sequence === undefined) {
    result.event_sequence = payloadEventSequence;
  }

  return result;
}

function normalizeDeliveryRecord(
  record: unknown,
  jobId: string,
  deliveryId: string,
  success: boolean,
  httpStatus: number | undefined,
  errorMessage: string | undefined,
  attemptedAt: Date,
  retryMetadata: WebhookRetryMetadata,
  eventSequence: number | undefined
): WebhookDeliveryResult {
  const source = asRecord(record) ?? {};
  const id = typeof source.id === 'string' ? source.id : crypto.randomUUID();
  const sourceHttpStatus = typeof source.http_status === 'number'
    ? source.http_status
    : httpStatus;

  return {
    id,
    jobId,
    success,
    httpStatus: sourceHttpStatus,
    // Response bodies are intentionally never returned or persisted. A
    // callback may echo credentials or source text in an error response.
    responseBody: undefined,
    errorMessage: boundedError(errorMessage),
    attemptedAt,
    ...retryMetadata,
    deliveryId,
    eventSequence,
  };
}

/**
 * Send one callback attempt.
 *
 * `stableDeliveryId` is retained as the fifth positional argument for old
 * callers. New callers may pass an options object instead. The timestamp is a
 * header-only replay nonce; it is regenerated on every call while the JSON
 * body and delivery identity remain unchanged for the same outbox row.
 */
export async function deliverWebhook(
  jobId: string,
  payload: WebhookPayload | Record<string, unknown>,
  callbackUrl: string,
  callbackSecret: string,
  stableDeliveryIdOrOptions?: string | WebhookDeliveryOptions,
  suppliedOptions?: WebhookDeliveryOptions
): Promise<WebhookDeliveryResult> {
  const positionalOptions: WebhookDeliveryOptions = typeof stableDeliveryIdOrOptions === 'object'
    ? stableDeliveryIdOrOptions
    : suppliedOptions ?? {};
  const stableDeliveryId = typeof stableDeliveryIdOrOptions === 'string'
    ? stableDeliveryIdOrOptions
    : positionalOptions.stableDeliveryId;
  if (stableDeliveryId !== undefined) {
    boundedIdentity(stableDeliveryId, 'delivery ID');
  }

  const attemptNumber = boundedInteger(positionalOptions.attemptNumber, 1, 1, MAX_CONFIGURED_ATTEMPTS);
  const maxAttempts = boundedAttempts(positionalOptions.maxAttempts ?? config.webhookMaxRetries);
  const attemptDate = toDate(positionalOptions.now);
  const sanitizedInput = sanitizeWebhookPayload(payload);
  const payloadDeliveryId = deliveryIdFromPayload(sanitizedInput);
  if (stableDeliveryId !== undefined
    && payloadDeliveryId !== undefined
    && stableDeliveryId !== payloadDeliveryId) {
    throw new Error('Webhook delivery ID does not match payload identity');
  }

  const payloadSubmissionId = submissionIdFromPayload(sanitizedInput);
  const expectedSubmissionId = positionalOptions.submissionId === undefined
    ? payloadSubmissionId
    : requiredIdentity(positionalOptions.submissionId, payloadSubmissionId, 'submission ID');
  const payloadEventSequence = eventSequenceFromPayload(sanitizedInput);
  if (positionalOptions.eventSequence !== undefined
    && (!Number.isSafeInteger(positionalOptions.eventSequence) || positionalOptions.eventSequence < 1)) {
    throw new Error('Webhook event sequence must be a positive integer');
  }
  if (positionalOptions.eventSequence !== undefined
    && payloadEventSequence !== undefined
    && positionalOptions.eventSequence !== payloadEventSequence) {
    throw new Error('Webhook event sequence does not match payload identity');
  }
  const expectedEventSequence = positionalOptions.eventSequence ?? payloadEventSequence;
  const expectedClientJobId = clientJobIdFromPayload(sanitizedInput);
  let deliveryId = stableDeliveryId || payloadDeliveryId || crypto.randomUUID();
  let eventSequence = expectedEventSequence;

  let body: string;
  if (positionalOptions.canonicalBody !== undefined) {
    body = positionalOptions.canonicalBody;
    const canonicalPayload = parseSecretFreeCanonicalBody(body);
    const canonicalDeliveryId = deliveryIdFromPayload(canonicalPayload);
    if (!canonicalDeliveryId) {
      throw new Error('Canonical webhook body is missing delivery ID');
    }
    if (stableDeliveryId !== undefined && canonicalDeliveryId !== stableDeliveryId) {
      throw new Error('Canonical webhook delivery ID does not match outbox identity');
    }
    if (payloadDeliveryId !== undefined && canonicalDeliveryId !== payloadDeliveryId) {
      throw new Error('Canonical webhook delivery ID does not match payload identity');
    }
    deliveryId = canonicalDeliveryId;

    const canonicalJobId = stringIdentityFromPayload(canonicalPayload, ['jobId', 'job_id'], 'job ID');
    if (!canonicalJobId || canonicalJobId !== jobId) {
      throw new Error('Canonical webhook job ID does not match delivery job');
    }

    const canonicalSubmissionId = submissionIdFromPayload(canonicalPayload);
    if (expectedSubmissionId !== undefined
      && (!canonicalSubmissionId || canonicalSubmissionId !== expectedSubmissionId)) {
      throw new Error('Canonical webhook submission ID does not match delivery identity');
    }

    const canonicalClientJobId = clientJobIdFromPayload(canonicalPayload);
    if (expectedClientJobId !== undefined
      && (!canonicalClientJobId || canonicalClientJobId !== expectedClientJobId)) {
      throw new Error('Canonical webhook client job ID does not match delivery identity');
    }

    const canonicalEventSequence = eventSequenceFromPayload(canonicalPayload);
    if (expectedEventSequence !== undefined
      && (canonicalEventSequence === undefined || canonicalEventSequence !== expectedEventSequence)) {
      throw new Error('Canonical webhook event sequence does not match delivery identity');
    }
    eventSequence = canonicalEventSequence ?? eventSequence;

    const expectedEvent = typeof sanitizedInput.event === 'string' ? sanitizedInput.event : undefined;
    const canonicalEvent = typeof canonicalPayload.event === 'string' ? canonicalPayload.event : undefined;
    if (expectedEvent !== undefined && canonicalEvent !== expectedEvent) {
      throw new Error('Canonical webhook event does not match delivery event');
    }

    // Validate that the caller supplied the same persisted content while keeping
    // the original bytes for signing. Object key order and JSON whitespace do
    // not change the meaning of a canonical body, so compare parsed values
    // instead of reserializing the request that will be sent.
    const expectedPayload = preparePayload(payload, deliveryId, {
      ...positionalOptions,
      submissionId: expectedSubmissionId,
      eventSequence: eventSequence,
    });
    const expectedCanonicalPayload = JSON.parse(stringifyWebhookPayload(expectedPayload)) as JsonRecord;
    if (!payloadValuesEqual(canonicalPayload, expectedCanonicalPayload)) {
      throw new Error('Canonical webhook body does not match sanitized payload');
    }
  } else {
    body = stringifyWebhookPayload(preparePayload(payload, deliveryId, positionalOptions));
  }

  const destination = boundedCallbackUrl(callbackUrl);
  const timestamp = timestampHeader(positionalOptions.now);
  const signature = crypto
    .createHmac('sha256', callbackSecret)
    .update(`${timestamp}.${body}`)
    .digest('hex');

  const headers: Record<string, string> = {
    'Content-Type': 'application/json',
    'X-Webhook-Signature': signature,
    'X-TPZ-Timestamp': timestamp,
    'User-Agent': 'TranslatePressZone-Webhook/1.0',
  };
  if (attemptNumber > 1) {
    headers['X-Retry-Attempt'] = String(attemptNumber);
  }

  let success = false;
  let httpStatus: number | undefined;
  let errorMessage: string | undefined;

  try {
    const response = await axios.post(destination, body, {
      headers,
      timeout: WEBHOOK_REQUEST_TIMEOUT_MS,
      validateStatus: (status) => status >= 200 && status < 300,
    });

    success = true;
    httpStatus = response.status;
    logger.info('Webhook delivered successfully', {
      jobId,
      deliveryId,
      event: asRecord(payload)?.event,
      attemptNumber,
      httpStatus,
    });
  } catch (error) {
    if (axios.isAxiosError(error)) {
      const axiosError = error as AxiosError;
      httpStatus = axiosError.response?.status;
      errorMessage = boundedError(axiosError.message) ?? 'Webhook delivery failed';
    } else if (error instanceof Error) {
      errorMessage = boundedError(error.message) ?? 'Webhook delivery failed';
    } else {
      errorMessage = 'Unknown error during webhook delivery';
    }

    logger.error('Webhook delivery failed', {
      jobId,
      deliveryId,
      event: asRecord(payload)?.event,
      attemptNumber,
      httpStatus,
    });
  }

  const duration = Math.max(0, Date.now() - attemptDate.getTime());
  trackWebhookDelivery(success, duration);

  const retryMetadata = buildRetryMetadata(
    success,
    httpStatus,
    attemptNumber,
    maxAttempts,
    attemptDate,
    positionalOptions
  );

  // Keep attempt history, but never copy the callback response body. The
  // durable WebhookOutbox row is settled by the worker with retryMetadata.
  let record: unknown;
  try {
    const data: Record<string, unknown> = {
      id: crypto.randomUUID(),
      job_id: jobId,
      attempt_number: attemptNumber,
      success,
      http_status: httpStatus,
      error_message: boundedError(errorMessage),
    };
    record = await prisma.webhookDelivery.create({ data: data as never });
  } catch (dbError) {
    // Attempt history is diagnostic and must not prevent the durable outbox row
    // from being settled. Throwing here after a successful HTTP response causes
    // the lease to expire and the same terminal event to be delivered again.
    logger.error('Failed to record webhook delivery', {
      jobId,
      deliveryId,
      error: boundedError(dbError),
    });
  }

  return normalizeDeliveryRecord(
    record,
    jobId,
    deliveryId,
    success,
    httpStatus,
    errorMessage,
    attemptDate,
    retryMetadata,
    eventSequence
  );
}

/**
 * Persist a terminal callback event before any HTTP call. The returned primary
 * key is also the callback's stable deliveryId. When no sequence is supplied,
 * the next sequence is calculated inside the caller's transaction using the
 * latest row for this job.
 */
export async function createWebhookOutbox(
  database: unknown,
  input: WebhookOutboxCreateInput
): Promise<WebhookOutboxEntry> {
  const db = asOutboxDatabase(database);
  if (!input.jobId || !input.event || !TERMINAL_WEBHOOK_EVENTS.has(input.event)) {
    throw new Error('Invalid terminal webhook outbox event');
  }

  const rawPayload = sanitizeWebhookPayload(input.payload);
  if (input.refs !== undefined) {
    rawPayload.refs = [...input.refs];
  }
  validateTerminalWebhookPayload(input, rawPayload);

  const latest = await db.webhookOutbox.findFirst({
    where: { job_id: input.jobId },
    orderBy: { delivery_sequence: 'desc' },
    select: { delivery_sequence: true },
  });
  const latestRecord = asRecord(latest);
  const latestSequence = typeof latestRecord?.delivery_sequence === 'number'
    && Number.isSafeInteger(latestRecord.delivery_sequence)
    && latestRecord.delivery_sequence >= 0
    ? latestRecord.delivery_sequence
    : 0;
  const payloadSequence = eventSequenceFromPayload(rawPayload);
  const requestedSequence = input.eventSequence ?? input.deliverySequence ?? payloadSequence;
  const eventSequence = Math.max(
    latestSequence + 1,
    boundedInteger(requestedSequence, latestSequence + 1, 1, Number.MAX_SAFE_INTEGER)
  );
  if (payloadSequence !== undefined && payloadSequence !== eventSequence) {
    throw new Error('Terminal webhook event sequence does not match outbox sequence');
  }
  const payloadDeliveryId = deliveryIdFromPayload(rawPayload);
  if (input.deliveryId !== undefined) {
    boundedIdentity(input.deliveryId, 'delivery ID');
  }
  if (input.deliveryId !== undefined && payloadDeliveryId !== undefined && input.deliveryId !== payloadDeliveryId) {
    throw new Error('Terminal webhook delivery ID does not match payload identity');
  }
  const deliveryId = input.deliveryId || crypto.randomUUID();
  rawPayload.deliveryId = deliveryId;
  rawPayload.event_sequence = eventSequence;

  const now = input.now ? new Date(input.now.getTime()) : new Date(Date.now());
  const storedPayload = sanitizeWebhookPayload(rawPayload);
  const data = {
    id: deliveryId,
    job_id: input.jobId,
    event: input.event,
    delivery_sequence: eventSequence,
    is_final: input.isFinal ?? true,
    payload: storedPayload,
    status: 'pending',
    attempts: 0,
    next_attempt_at: now,
  };
  const created = await db.webhookOutbox.create({ data });
  const createdRecord = asRecord(created) ?? {};

  return {
    id: typeof createdRecord.id === 'string' ? createdRecord.id : deliveryId,
    job_id: typeof createdRecord.job_id === 'string' ? createdRecord.job_id : input.jobId,
    event: typeof createdRecord.event === 'string' ? createdRecord.event : input.event,
    delivery_sequence: typeof createdRecord.delivery_sequence === 'number'
      ? createdRecord.delivery_sequence
      : eventSequence,
    payload: createdRecord.payload ?? storedPayload,
    status: typeof createdRecord.status === 'string' ? createdRecord.status : 'pending',
    attempts: typeof createdRecord.attempts === 'number' ? createdRecord.attempts : 0,
    next_attempt_at: createdRecord.next_attempt_at instanceof Date ? createdRecord.next_attempt_at : now,
    first_attempt_at: createdRecord.first_attempt_at instanceof Date ? createdRecord.first_attempt_at : null,
    last_attempt_at: createdRecord.last_attempt_at instanceof Date ? createdRecord.last_attempt_at : null,
    dead_at: createdRecord.dead_at instanceof Date ? createdRecord.dead_at : null,
    response_status: typeof createdRecord.response_status === 'number' ? createdRecord.response_status : null,
    claimed_at: createdRecord.claimed_at instanceof Date ? createdRecord.claimed_at : null,
  };
}

/**
 * Claim due rows using a compare-and-swap update. Stale delivery leases are
 * returned to pending before the due scan. Secrets are returned only in memory
 * to the sender and are never included in logs or database writes by this
 * module.
 */
export async function claimDueWebhookOutbox(
  database: unknown = prisma,
  options: WebhookOutboxClaimOptions = {}
): Promise<WebhookOutboxEntry[]> {
  const db = asOutboxDatabase(database);
  const now = options.now ? new Date(options.now.getTime()) : new Date(Date.now());
  const leaseMs = boundedInteger(options.leaseMs, DEFAULT_OUTBOX_LEASE_MS, 1_000, 15 * 60_000);
  const maxAttempts = boundedAttempts(options.maxAttempts ?? config.webhookMaxRetries);
  const batchSize = boundedInteger(options.batchSize, DEFAULT_OUTBOX_BATCH_SIZE, 1, MAX_OUTBOX_BATCH_SIZE);
  const staleAt = new Date(now.getTime() - leaseMs);

  await db.webhookOutbox.updateMany({
    where: { status: 'delivering', claimed_at: { lt: staleAt } },
    data: { status: 'pending', claimed_at: null, next_attempt_at: now },
  });
  await db.webhookOutbox.updateMany({
    where: {
      status: 'retry_wait',
      OR: [{ next_attempt_at: null }, { next_attempt_at: { lte: now } }],
    },
    data: { status: 'pending', claimed_at: null },
  });
  await db.webhookOutbox.updateMany({
    where: { status: { in: ['pending', 'retry_wait'] }, attempts: { gte: maxAttempts } },
    data: { status: 'dead', dead_at: now, next_attempt_at: null, claimed_at: null },
  });

  const dueRows = await db.webhookOutbox.findMany({
    where: {
      status: 'pending',
      attempts: { lt: maxAttempts },
      OR: [{ next_attempt_at: null }, { next_attempt_at: { lte: now } }],
    },
    orderBy: [{ next_attempt_at: 'asc' }, { created_at: 'asc' }],
    take: batchSize,
    include: {
      job: { select: { callback_url: true, callback_secret: true } },
    },
  });

  const claimedRows: WebhookOutboxEntry[] = [];
  for (const candidate of dueRows) {
    const row = asRecord(candidate);
    if (!row || typeof row.id !== 'string') {
      continue;
    }
    const claimedAt = new Date(now.getTime());
    const firstAttemptAt = row.first_attempt_at instanceof Date ? undefined : claimedAt;
    const updateData: Record<string, unknown> = {
      status: 'delivering',
      attempts: { increment: 1 },
      claimed_at: claimedAt,
    };
    if (firstAttemptAt) {
      updateData.first_attempt_at = firstAttemptAt;
    }

    const claimed = await db.webhookOutbox.updateMany({
      where: {
        id: row.id,
        status: 'pending',
        attempts: { lt: maxAttempts },
        OR: [{ next_attempt_at: null }, { next_attempt_at: { lte: now } }],
      },
      data: updateData,
    });
    if (claimed.count !== 1) {
      continue;
    }

    const attempts = typeof row.attempts === 'number' ? row.attempts + 1 : 1;
    const rowJob = asRecord(row.job);
    claimedRows.push({
      id: row.id,
      job_id: typeof row.job_id === 'string' ? row.job_id : '',
      event: typeof row.event === 'string' ? row.event : '',
      delivery_sequence: typeof row.delivery_sequence === 'number' ? row.delivery_sequence : 0,
      payload: row.payload,
      status: 'delivering',
      attempts,
      next_attempt_at: row.next_attempt_at instanceof Date ? row.next_attempt_at : null,
      first_attempt_at: row.first_attempt_at instanceof Date ? row.first_attempt_at : claimedAt,
      last_attempt_at: row.last_attempt_at instanceof Date ? row.last_attempt_at : null,
      dead_at: row.dead_at instanceof Date ? row.dead_at : null,
      response_status: typeof row.response_status === 'number' ? row.response_status : null,
      claimed_at: claimedAt,
      claimedAt,
      attemptNumber: attempts,
      job: rowJob
        ? {
          callback_url: typeof rowJob.callback_url === 'string' ? rowJob.callback_url : null,
          callback_secret: typeof rowJob.callback_secret === 'string' ? rowJob.callback_secret : null,
        }
        : undefined,
    });
  }

  return claimedRows;
}

/**
 * Persist the bounded result of a claimed delivery. The claimed timestamp is
 * part of the compare-and-swap predicate so a stale worker cannot settle a row
 * that has already been reclaimed by another worker.
 */
export async function settleWebhookOutbox(
  database: unknown,
  settlement: WebhookOutboxSettlement
): Promise<boolean> {
  const db = asOutboxDatabase(database);
  const now = settlement.now ? new Date(settlement.now.getTime()) : new Date(Date.now());
  const result = settlement.result;
  const status = result.status;
  const data: Record<string, unknown> = {
    status,
    claimed_at: null,
    last_attempt_at: now,
    response_status: result.httpStatus ?? null,
    last_error: result.success ? null : boundedError(result.errorMessage) ?? 'Webhook delivery failed',
    next_attempt_at: result.nextAttemptAt ?? null,
    dead_at: status === 'dead' ? now : null,
    delivered_at: result.success ? now : null,
  };

  const updated = await db.webhookOutbox.updateMany({
    where: {
      id: settlement.entryId,
      status: 'delivering',
      claimed_at: settlement.claimedAt,
    },
    data,
  });
  return updated.count === 1;
}

/** Reset a dead row into a fresh bounded retry window for an admin redrive. */
export async function redriveWebhookOutbox(
  database: unknown,
  entryId: string,
  now: Date = new Date(Date.now())
): Promise<boolean> {
  const db = asOutboxDatabase(database);
  const updated = await db.webhookOutbox.updateMany({
    where: { id: entryId, status: 'dead' },
    data: {
      status: 'pending',
      attempts: 0,
      claimed_at: null,
      next_attempt_at: now,
      first_attempt_at: null,
      last_attempt_at: null,
      dead_at: null,
      response_status: null,
      last_error: null,
      delivered_at: null,
    },
  });
  return updated.count === 1;
}

/**
 * Deliver a row returned by claimDueWebhookOutbox. Object payloads are
 * canonicalized once here; a caller may provide exact persisted request bytes
 * through `canonicalBody` when the storage layer has them.
 */
export async function deliverPersistedWebhook(
  entry: WebhookOutboxEntry,
  callbackUrl: string,
  callbackSecret: string,
  options: Omit<WebhookDeliveryOptions, 'stableDeliveryId' | 'eventSequence' | 'attemptNumber'> = {}
): Promise<WebhookDeliveryResult> {
  const attemptNumber = entry.attemptNumber ?? Math.max(1, entry.attempts || 1);
  const payload = typeof entry.payload === 'string'
    ? {}
    : sanitizeWebhookPayload(entry.payload);
  const canonicalBody = typeof entry.payload === 'string'
    ? entry.payload
    : JSON.stringify(preparePayload(payload, entry.id, {
      submissionId: typeof payload.submission_id === 'string' ? payload.submission_id : undefined,
      eventSequence: entry.delivery_sequence,
    }));

  return deliverWebhook(
    entry.job_id,
    payload,
    boundedCallbackUrl(callbackUrl),
    callbackSecret,
    entry.id,
    {
      ...options,
      submissionId: submissionIdFromPayload(payload) ?? options.submissionId,
      attemptNumber,
      eventSequence: entry.delivery_sequence,
      canonicalBody,
    }
  );
}

/**
 * Retry the legacy direct-delivery path. Durable workers should prefer
 * deliverPersistedWebhook; this wrapper remains for existing queue callers and
 * uses the latest outbox identity when one exists.
 */
export async function retryFailedWebhook(
  jobId: string,
  attemptNumber: number
): Promise<WebhookDeliveryResult> {
  const maxAttempts = boundedAttempts(config.webhookMaxRetries);
  if (attemptNumber > maxAttempts) {
    throw new Error(`Maximum retry attempts (${maxAttempts}) exceeded for job ${jobId}`);
  }

  const job = await prisma.translationJob.findUnique({
    where: { id: jobId },
    select: {
      id: true,
      user_id: true,
      client_job_id: true,
      submission_id: true,
      status: true,
      source_lang: true,
      target_lang: true,
      model: true,
      tone: true,
      translation: true,
      characters_used: true,
      tokens_used: true,
      cost: true,
      customer_cost: true,
      error_message: true,
      processing_time_ms: true,
      callback_url: true,
      callback_secret: true,
      completed_at: true,
    },
  });

  if (!job) {
    throw new Error(`Translation job ${jobId} not found`);
  }
  if (!job.callback_url || !job.callback_secret) {
    throw new Error(`Job ${jobId} has no callback URL or secret configured`);
  }

  const event = job.status === TranslationJobStatus.completed
    ? 'translation.completed'
    : 'translation.failed';
  const outboxDb = asOutboxDatabase(prisma);
  let latestOutbox: JsonRecord | null = null;
  try {
    latestOutbox = asRecord(await outboxDb.webhookOutbox.findFirst({
      where: { job_id: jobId, event },
      orderBy: { delivery_sequence: 'desc' },
      select: { id: true, delivery_sequence: true, payload: true },
    }));
  } catch {
    // Older generated clients may not have the additive outbox delegate. The
    // legacy payload below remains a valid callback in that environment.
    latestOutbox = null;
  }

  let payload: WebhookPayload | Record<string, unknown>;
  let canonicalBody: string | undefined;
  let eventSequence: number | undefined;
  let stableDeliveryId: string | undefined;
  if (latestOutbox?.payload !== undefined) {
    payload = sanitizeWebhookPayload(latestOutbox.payload);
    stableDeliveryId = typeof latestOutbox.id === 'string' ? latestOutbox.id : undefined;
    eventSequence = typeof latestOutbox.delivery_sequence === 'number'
      ? latestOutbox.delivery_sequence
      : undefined;
    canonicalBody = JSON.stringify(preparePayload(payload, stableDeliveryId ?? crypto.randomUUID(), {
      eventSequence,
      submissionId: typeof job.submission_id === 'string' ? job.submission_id : undefined,
    }));
  } else {
    const storedTranslation = getStructuredWebhookResult(job.translation);
    payload = {
      event,
      jobId: job.id,
      clientJobId: job.client_job_id ?? undefined,
      submission_id: job.submission_id ?? undefined,
      status: job.status as WebhookPayload['status'],
      ...storedTranslation,
      charactersUsed: job.characters_used,
      cost: job.customer_cost.toNumber(),
      errorMessage: job.error_message ?? undefined,
      processingTimeMs: job.processing_time_ms ?? undefined,
      timestamp: job.completed_at?.toISOString() ?? new Date().toISOString(),
    };
  }

  const delayMs = retryDelayMs(
    attemptNumber,
    boundedBaseDelay(config.webhookRetryDelayMs),
    MAX_RETRY_DELAY_MS,
    0,
    () => 0
  );
  if (delayMs > 0) {
    await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
  }

  return deliverWebhook(
    job.id,
    payload,
    job.callback_url,
    job.callback_secret,
    stableDeliveryId,
    {
      attemptNumber,
      maxAttempts,
      eventSequence,
      submissionId: typeof job.submission_id === 'string' ? job.submission_id : undefined,
      canonicalBody,
      jitterMs: 0,
    }
  );
}

/** Return sanitized attempt history without callback response bodies. */
export async function getWebhookDeliveries(
  jobId: string
): Promise<WebhookDelivery[]> {
  try {
    const deliveries = await prisma.webhookDelivery.findMany({
      where: { job_id: jobId },
      orderBy: { attempt_number: 'asc' },
    });

    return deliveries.map((delivery) => {
      const row = asRecord(delivery) ?? {};
      return {
        id: typeof row.id === 'string' ? row.id : '',
        jobId,
        attemptNumber: typeof row.attempt_number === 'number' ? row.attempt_number : 0,
        success: row.success === true,
        httpStatus: typeof row.http_status === 'number' ? row.http_status : undefined,
        responseBody: undefined,
        errorMessage: boundedError(row.error_message),
        attemptedAt: row.attempted_at instanceof Date ? row.attempted_at : new Date(0),
      };
    });
  } catch (error) {
    logger.error('Failed to get webhook deliveries', { jobId, error: boundedError(error) });
    throw error;
  }
}

/**
 * Check whether the latest legacy delivery is eligible for another attempt.
 * Durable outbox rows use their own status/next_attempt_at fields instead.
 */
export async function shouldRetryWebhook(jobId: string): Promise<boolean> {
  try {
    const lastDelivery = await prisma.webhookDelivery.findFirst({
      where: { job_id: jobId },
      orderBy: { attempt_number: 'desc' },
    });

    if (!lastDelivery || lastDelivery.success) {
      return false;
    }
    if (lastDelivery.http_status !== null && lastDelivery.http_status !== undefined && !retryableHttpStatus(lastDelivery.http_status)) {
      return false;
    }
    return lastDelivery.attempt_number < boundedAttempts(config.webhookMaxRetries);
  } catch (error) {
    logger.error('Failed to check webhook retry status', { jobId, error: boundedError(error) });
    throw error;
  }
}

/** Return the next bounded legacy attempt number, or null after exhaustion. */
export async function getNextRetryAttempt(
  jobId: string
): Promise<number | null> {
  try {
    const lastDelivery = await prisma.webhookDelivery.findFirst({
      where: { job_id: jobId },
      orderBy: { attempt_number: 'desc' },
    });

    if (!lastDelivery) {
      return 1;
    }

    const nextAttempt = lastDelivery.attempt_number + 1;
    return nextAttempt > boundedAttempts(config.webhookMaxRetries) ? null : nextAttempt;
  } catch (error) {
    logger.error('Failed to get next webhook retry attempt', { jobId, error: boundedError(error) });
    throw error;
  }
}
