/**
 * Phase 3 - Custom document upload-to-Documenso.
 *
 * The Contract + Reservation tabs upload a draft PDF, configure
 * recipients + fields, and hand the bundle to Documenso for signing.
 * This service is the backend foundation; the UI dialog (Phase 4)
 * eventually POSTs to /api/v1/interests/[id]/upload-for-signing which
 * delegates here.
 *
 * Flow:
 *   1. Magic-byte verify the PDF (defense vs. mislabelled bytes -
 *      same posture as berth-pdf + brochures).
 *   2. Insert a `files` row + push the PDF into storage. The row is
 *      port-scoped + entity-scoped (interest) so it appears in the
 *      Documents tab + the interest's entity folder.
 *   3. Insert a `documents` row in `draft` status linked to the
 *      interest + the source file.
 *   4. Documenso round-trip: createDocument → placeFields → sendDocument.
 *      Per-port apiVersion drives v1 vs v2 routing (existing client
 *      handles both - v1: legacy /api/v1/documents; v2: envelope/create
 *      multipart).
 *   5. Capture per-recipient signingUrl + token into `document_signers`
 *      so the webhook cascade picks them up (Phase 2).
 *   6. If the port's `eoi_send_mode === 'auto'`, fire the branded
 *      invitation to the first signer immediately + stamp `invitedAt`.
 *      Manual mode leaves it to the rep's "Send invitation" button.
 *
 * Multi-tenant guard: the interest is read with both `id` AND `portId`
 * filters; cross-port upload attempts return NotFoundError before any
 * Documenso traffic.
 */

import { and, eq } from 'drizzle-orm';

import { db } from '@/lib/db';
import { documents, documentSigners, files } from '@/lib/db/schema/documents';
import { interests } from '@/lib/db/schema/interests';
import { clients } from '@/lib/db/schema/clients';
import { ports } from '@/lib/db/schema/ports';
import { buildStoragePath } from '@/lib/minio';
import { env } from '@/lib/env';
import { getStorageBackend } from '@/lib/storage';
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { isPdfMagic } from '@/lib/services/berth-pdf-parser';
import {
  createDocument as documensoCreate,
  sendDocument as documensoSend,
  placeFields,
  voidDocument as documensoVoid,
  type DocumensoFieldPlacement,
  type DocumensoRecipient,
} from '@/lib/services/documenso-client';
import { getPortDocumensoConfig } from '@/lib/services/port-config';
import {
  sendSigningInvitation,
  type SignerRole,
} from '@/lib/services/document-signing-emails.service';
import { DOC_TYPE_LABEL, extractSigningToken } from '@/lib/services/documenso-signers';
import { ensureEntityFolder } from '@/lib/services/document-folders.service';
import { advanceStageIfBehind } from '@/lib/services/interests.service';
import { emitToRoom } from '@/lib/socket/server';
import { logger } from '@/lib/logger';

/** Document types this service accepts. EOI / contract /
 *  reservation_agreement each follow the same upload-PDF +
 *  place-fields + send-to-Documenso flow with per-type pipeline stage
 *  + doc-status side effects. `'generic'` is the universal path -
 *  used by the cross-cutting "any uploaded file can be a signing
 *  envelope" feature: no pipeline advance, no doc-status flip, just a
 *  files + documents row marked `sent`. The template-driven EOI
 *  generation lives in `document-templates.ts` and follows a
 *  different route. */
export type CustomDocumentType = 'eoi' | 'contract' | 'reservation_agreement' | 'generic';

/** Documenso recipient role - narrowed from the full enum to the
 *  three values the custom-upload flow accepts. APPROVER + CC are
 *  documented in plan Q4. VIEWER + ASSISTANT are out of scope for
 *  marina contracts today. */
export type CustomRecipientRole = 'SIGNER' | 'APPROVER' | 'CC';

export interface CustomDocumentRecipient {
  name: string;
  email: string;
  role: CustomRecipientRole;
  signingOrder: number;
}

export interface UploadDocumentForSigningArgs {
  /** Optional interest the doc is filed under. Required for eoi /
   *  contract / reservation_agreement (their pipeline-stage side
   *  effects need it); MUST be null for 'generic' (cross-cutting
   *  envelopes that aren't tied to a sales deal). */
  interestId: string | null;
  /** Optional entity context — drives the auto-filed folder + the
   *  file-row FK. Used by the 'generic' path when there's no interest
   *  to derive the client from. Ignored when `interestId` is set
   *  (the service resolves the client off the interest itself). */
  entity?: { type: 'client' | 'company' | 'yacht'; id: string } | null;
  /** Optional explicit folder placement. When set, overrides the
   *  entity-derived folder (e.g. rep dropped the upload into a
   *  specific subfolder from the Documents Hub). */
  folderId?: string | null;
  portId: string;
  portSlug: string;
  documentType: CustomDocumentType;
  title: string;
  pdfBuffer: Buffer;
  filename: string;
  recipients: CustomDocumentRecipient[];
  /** Field placements come from Phase 4's drag-drop UI or auto-detect.
   *  `recipientId` is the INDEX into `recipients` - the service maps
   *  it to the resolved Documenso recipient id after createDocument
   *  responds. */
  fields: Array<Omit<DocumensoFieldPlacement, 'recipientId'> & { recipientIndex: number }>;
  /** Phase 6 polish - optional rep-authored note inserted above the
   *  CTA in every signing-invitation email for this document. Stored
   *  on documents.invitation_message; falls back to the template
   *  default when null/empty. */
  invitationMessage?: string | null;
  meta: AuditMeta;
}

export interface UploadDocumentForSigningResult {
  documentId: string;
  documensoDocumentId: string;
  /** Map of recipient email → branded embedded signing URL. The UI
   *  exposes these so a rep can copy a link out for manual delivery in
   *  manual-send mode. */
  signingUrls: Record<string, string>;
}

const PDF_MIME = 'application/pdf';
const MAX_PDF_BYTES = 50 * 1024 * 1024; // 50 MB - matches MAX_FILE_SIZE default

export async function uploadDocumentForSigning(
  args: UploadDocumentForSigningArgs,
): Promise<UploadDocumentForSigningResult> {
  const {
    interestId,
    entity,
    folderId: explicitFolderId,
    portId,
    portSlug,
    documentType,
    title,
    pdfBuffer,
    filename,
    recipients,
    fields,
    invitationMessage,
    meta,
  } = args;

  // Generic envelopes (no pipeline-stage advance / no interest) MUST
  // come in with interestId=null; non-generic types MUST carry an
  // interest. Reject the mismatch here so the rest of the function can
  // assume the right invariant.
  if (documentType !== 'generic' && !interestId) {
    throw new ValidationError(
      `${documentType} document requires an interestId — only 'generic' documents can be uploaded without one`,
    );
  }
  if (documentType === 'generic' && interestId) {
    throw new ValidationError(
      'Generic documents cannot carry an interestId — use a type-specific document type instead',
    );
  }

  // ─── Validation ──────────────────────────────────────────────────
  if (recipients.length === 0) {
    throw new ValidationError('At least one recipient is required');
  }
  if (fields.length === 0) {
    throw new ValidationError('At least one field placement is required');
  }
  if (pdfBuffer.length === 0) {
    throw new ValidationError('PDF buffer is empty');
  }
  if (pdfBuffer.length > MAX_PDF_BYTES) {
    throw new ValidationError(`PDF exceeds ${MAX_PDF_BYTES / 1024 / 1024} MB cap`);
  }
  if (!isPdfMagic(pdfBuffer)) {
    throw new ValidationError('Uploaded file is not a PDF (magic-byte check failed)');
  }
  // Every field's recipientIndex must reference a real recipient. Out-
  // of-range indexes silently maps to undefined in the recipient lookup
  // below - fail loudly here instead.
  for (const f of fields) {
    if (f.recipientIndex < 0 || f.recipientIndex >= recipients.length) {
      throw new ValidationError(
        `Field recipientIndex=${f.recipientIndex} is out of range (have ${recipients.length} recipients)`,
      );
    }
  }
  // Recipient-level validation — emails and names. Documenso's API
  // can't recover from a missing/invalid email (it'll silently drop the
  // recipient or accept the envelope without distribution), so hard
  // block at the service boundary. The UI mirrors this validation
  // pre-Submit so the rep sees the issue before the round-trip.
  const recipientEmailRegex = /^\S+@\S+\.\S+$/;
  for (const r of recipients) {
    const email = (r.email ?? '').trim();
    if (!email) {
      throw new ValidationError(`Recipient #${r.signingOrder} is missing an email address`);
    }
    if (!recipientEmailRegex.test(email)) {
      throw new ValidationError(
        `Recipient #${r.signingOrder} has an invalid email address: ${email}`,
      );
    }
    const name = (r.name ?? '').trim();
    if (!name) {
      throw new ValidationError(`Recipient #${r.signingOrder} is missing a name`);
    }
  }
  // Defense-in-depth: a duplicate signing-order would let Documenso
  // accept the doc but break the cascading-invite logic (next signer
  // picker assumes a strict ordering).
  const orders = new Set<number>();
  for (const r of recipients) {
    if (orders.has(r.signingOrder)) {
      throw new ValidationError(`Duplicate signingOrder=${r.signingOrder} in recipients`);
    }
    orders.add(r.signingOrder);
  }

  // ─── Tenant guard ────────────────────────────────────────────────
  // Non-generic types resolve their interest (and derive the client
  // from there). Generic types skip the interest lookup; entity FK
  // routing comes from the caller-supplied `entity` arg.
  const interest = interestId
    ? await db.query.interests.findFirst({
        where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
      })
    : null;
  if (interestId && !interest) throw new NotFoundError('Interest');

  const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
  if (!port) throw new NotFoundError('Port');

  // ─── Store source PDF ────────────────────────────────────────────
  // The source PDF needs to live in storage so reps + admins can view
  // the pre-signed draft in the Files tab. We also use the resolved
  // storage key as the `documents.fileId` reference.
  const sourceFileId = crypto.randomUUID();
  // Storage path category mirrors documentType so admins poking at
  // the bucket can tell at a glance what each blob is. Generic
  // envelopes land under `signed-source` (uploaded for signing but no
  // pipeline-stage context).
  const storageCategory =
    documentType === 'contract'
      ? 'contract-source'
      : documentType === 'reservation_agreement'
        ? 'reservation-source'
        : documentType === 'eoi'
          ? 'eoi-source'
          : 'signed-source';
  // Storage path groups by interestId when we have one; for generic
  // uploads the entity id (or a synthetic 'unfiled' bucket) keeps the
  // namespace tidy.
  const storageGroupId = interestId ?? entity?.id ?? 'unfiled';
  const sourceStoragePath = buildStoragePath(
    portSlug,
    storageCategory,
    storageGroupId,
    sourceFileId,
    'pdf',
  );

  const storage = await getStorageBackend();
  await storage.put(sourceStoragePath, pdfBuffer, {
    contentType: PDF_MIME,
    sizeBytes: pdfBuffer.length,
  });

  // Folder placement priority:
  //   1. Caller-supplied `folderId` (rep dropped the upload into a
  //      specific Documents Hub folder).
  //   2. Interest's primary client folder (legacy path for
  //      EOI/contract/reservation tabs).
  //   3. Caller-supplied entity (generic path: client/company/yacht
  //      doc tab originated the upload).
  //   4. Root (fallback).
  let entityFolderId: string | null = explicitFolderId ?? null;
  if (entityFolderId === null && interest?.clientId) {
    try {
      const folder = await ensureEntityFolder(portId, 'client', interest.clientId, 'system');
      entityFolderId = folder.id;
    } catch (err) {
      logger.warn(
        { err, interestId, clientId: interest.clientId },
        'ensureEntityFolder failed during custom-document-upload - filing at root',
      );
    }
  }
  if (entityFolderId === null && entity) {
    try {
      const folder = await ensureEntityFolder(portId, entity.type, entity.id, 'system');
      entityFolderId = folder.id;
    } catch (err) {
      logger.warn(
        { err, entity },
        'ensureEntityFolder failed for generic upload entity - filing at root',
      );
    }
  }

  // Derive the entity-FK fields on the `files` row from whichever
  // source we have. Interest-derived takes priority; otherwise the
  // generic `entity` arg maps to its corresponding column.
  const fileEntityFKs: {
    clientId: string | null;
    companyId: string | null;
    yachtId: string | null;
  } = {
    clientId: interest?.clientId ?? (entity?.type === 'client' ? entity.id : null),
    companyId: entity?.type === 'company' ? entity.id : null,
    yachtId: entity?.type === 'yacht' ? entity.id : null,
  };

  const [sourceFileRecord] = await db
    .insert(files)
    .values({
      portId,
      clientId: fileEntityFKs.clientId,
      companyId: fileEntityFKs.companyId,
      yachtId: fileEntityFKs.yachtId,
      folderId: entityFolderId,
      filename,
      originalName: filename,
      mimeType: PDF_MIME,
      sizeBytes: String(pdfBuffer.length),
      storagePath: sourceStoragePath,
      storageBucket: env.MINIO_BUCKET,
      category: documentType,
      uploadedBy: meta.userId,
    })
    .returning();
  if (!sourceFileRecord) {
    // Best-effort compensating delete - we put a blob but the DB row
    // failed to land, leaving an orphan otherwise.
    await storage.delete(sourceStoragePath).catch(() => {});
    throw new ConflictError('Failed to record source file');
  }

  // ─── Insert the document row (status=draft) ───────────────────────
  const [docRow] = await db
    .insert(documents)
    .values({
      portId,
      interestId,
      clientId: fileEntityFKs.clientId,
      companyId: fileEntityFKs.companyId,
      yachtId: fileEntityFKs.yachtId,
      fileId: sourceFileRecord.id,
      documentType,
      title,
      status: 'draft',
      invitationMessage: invitationMessage?.trim() || null,
      createdBy: meta.userId,
    })
    .returning();
  if (!docRow) throw new ConflictError('Failed to insert document row');

  // ─── Local signer rows (pre-Documenso) ────────────────────────────
  // Insert with status=pending; we'll fill signingUrl + signingToken
  // after Documenso responds.
  const signerRows = await db
    .insert(documentSigners)
    .values(
      recipients.map((r) => ({
        documentId: docRow.id,
        signerName: r.name,
        signerEmail: r.email,
        // Map Documenso's enum back to our internal role taxonomy.
        // APPROVER + CC both render with passive-recipient copy in our
        // email templates.
        signerRole: documensoRoleToLocal(r.role),
        signingOrder: r.signingOrder,
        status: 'pending' as const,
      })),
    )
    .returning();

  // ─── Documenso round-trip ────────────────────────────────────────
  const docCfg = await getPortDocumensoConfig(portId);
  const pdfBase64 = pdfBuffer.toString('base64');

  const documensoRecipients: DocumensoRecipient[] = recipients.map((r) => ({
    name: r.name,
    email: r.email,
    role: r.role,
    signingOrder: r.signingOrder,
  }));

  // ─── State machine: create → send → place → promote ────────────
  // Every step that can fail registers its rollback contribution on
  // `state`. The single catch at the end runs `rollbackTo(state)`,
  // which composes the recovery: status='cancelled' on the local row
  // always, void the Documenso envelope only when we created one.
  // Idempotent — calling it twice is safe (status flip is a no-op the
  // second time, voidDocument treats 404 as success).
  type UploadStep = 'create' | 'send' | 'place' | 'promote';
  interface UploadState {
    step: UploadStep | null;
    documensoDocId: string | null;
  }
  const state: UploadState = { step: null, documensoDocId: null };
  // Cache the row id in a local const so the closure below doesn't fight
  // TypeScript's `Row | undefined` narrowing through the closure
  // boundary. The if (!docRow) guard above already established it's
  // defined here.
  const docRowId = docRow.id;

  async function rollbackTo(reason: unknown): Promise<void> {
    logger.warn(
      {
        documentId: docRowId,
        documensoEnvelopeId: state.documensoDocId,
        failedStep: state.step,
        err: reason instanceof Error ? { message: reason.message, name: reason.name } : reason,
      },
      'Rolling back custom document upload',
    );
    await db
      .update(documents)
      .set({ status: 'cancelled', updatedAt: new Date() })
      .where(eq(documents.id, docRowId));
    if (state.documensoDocId) {
      await documensoVoidSafe(state.documensoDocId, portId);
    }
    // Failure audit-log entry — captures which step failed, the
    // Documenso envelope id (if any), and the error class/message so
    // post-mortem investigation doesn't have to dig through structured
    // logs. Success-path audit is at the end of the function; this is
    // the failure-path counterpart.
    void createAuditLog({
      userId: meta.userId,
      portId,
      action: 'update',
      entityType: 'document',
      entityId: docRowId,
      newValue: {
        status: 'cancelled',
        failedStep: state.step ?? 'unknown',
        documensoEnvelopeId: state.documensoDocId,
        errorClass: reason instanceof Error ? reason.name : null,
        errorMessage: reason instanceof Error ? reason.message : String(reason),
      },
      metadata: {
        type: 'upload_for_signing_rollback',
      },
      ipAddress: meta.ipAddress,
      userAgent: meta.userAgent,
    });
  }

  let documensoDoc: Awaited<ReturnType<typeof documensoCreate>>;
  let sentDoc: Awaited<ReturnType<typeof documensoSend>>;

  try {
    // Step 1 — create envelope in Documenso.
    state.step = 'create';
    documensoDoc = await documensoCreate(title, pdfBase64, documensoRecipients, portId, {
      ...(docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}),
      ...(docCfg.redirectUrl ? { redirectUrl: docCfg.redirectUrl } : {}),
    });
    state.documensoDocId = documensoDoc.id;

    // Persist documensoId IMMEDIATELY so any subsequent failure leaves
    // the CRM row pointing at the envelope. Without this, a throw
    // between documensoCreate and the late `status: 'sent'` update
    // would orphan the envelope in Documenso (CRM has the local row but
    // no link to find the upstream envelope to void). UAT 2026-05-26
    // hit exactly this: CRM document row stuck in 'draft' status,
    // documensoId=NULL, Documenso hosting a live envelope nothing
    // referenced. The late `status: 'sent'` UPDATE still runs and the
    // idempotent re-write of documensoId is fine.
    await db
      .update(documents)
      .set({ documensoId: documensoDoc.id, updatedAt: new Date() })
      .where(eq(documents.id, docRow.id));

    // Step 2 — distribute (Documenso v2) / send (v1). Resolves the
    // recipient ids that we need for field placement next.
    state.step = 'send';
    sentDoc = await documensoSend(documensoDoc.id, portId);

    // Step 3 — recipient identity reconciliation + field placement.
    // Documenso de-dupes by email at the envelope level, so multiple
    // CRM-side Recipient rows that share an email all map to the same
    // Documenso recipientId — that's fine for field placement (both
    // rows target the same Documenso recipient).
    state.step = 'place';
    const emailToRecipientId = new Map<string, string>();
    for (const dr of sentDoc.recipients) {
      if (dr.email) emailToRecipientId.set(dr.email.toLowerCase(), dr.id);
    }

    // Reconciliation guard: every distinct CRM email we sent must
    // appear in sentDoc.recipients. If Documenso silently dropped one
    // (invalid email format that passed our regex, etc.), we want a
    // loud failure that triggers the rollback path — NOT a half-placed
    // doc that ships to signers with missing fields.
    const sentEmails = new Set(Array.from(emailToRecipientId.keys()).map((k) => k.toLowerCase()));
    const missingFromDocumenso = recipients
      .map((r) => r.email.trim().toLowerCase())
      .filter((email, idx, arr) => arr.indexOf(email) === idx) // dedupe
      .filter((email) => !sentEmails.has(email));
    if (missingFromDocumenso.length > 0) {
      logger.error(
        {
          documentId: docRow.id,
          documensoEnvelopeId: documensoDoc.id,
          missingFromDocumenso,
          documensoReturned: Array.from(emailToRecipientId.keys()),
        },
        'Recipient reconciliation: Documenso response missing emails the CRM sent',
      );
      throw new ConflictError(
        `Documenso accepted the envelope but didn't echo recipient(s) for: ${missingFromDocumenso.join(', ')}. ` +
          `Cannot place fields — recipients aren't reachable.`,
      );
    }

    // Build placements + place fields inside the same try block so the
    // synchronous map() throw (when a recipient can't be matched)
    // triggers rollback alongside any async placeFields() throw.
    const placements: DocumensoFieldPlacement[] = fields.map((f) => {
      const recipient = recipients[f.recipientIndex]!;
      const recipientId = emailToRecipientId.get(recipient.email.toLowerCase());
      if (!recipientId) {
        logger.error(
          {
            documentId: docRow.id,
            documensoEnvelopeId: documensoDoc.id,
            lookedUpEmail: recipient.email,
            availableEmails: Array.from(emailToRecipientId.keys()),
          },
          'Documenso recipient lookup miss during field placement',
        );
        throw new ConflictError(
          `Documenso response missing recipientId for ${recipient.email} - cannot place fields`,
        );
      }
      return {
        recipientId,
        type: f.type,
        pageNumber: f.pageNumber,
        pageX: f.pageX,
        pageY: f.pageY,
        pageWidth: f.pageWidth,
        pageHeight: f.pageHeight,
        ...(f.fieldMeta ? { fieldMeta: f.fieldMeta } : {}),
      };
    });
    await placeFields(documensoDoc.id, placements, portId);
  } catch (err) {
    await rollbackTo(err);
    throw err;
  }

  // Update local signers with signingUrl + token from Documenso.
  const signingUrls: Record<string, string> = {};
  for (const dr of sentDoc.recipients) {
    const local = signerRows.find((s) => s.signerEmail.toLowerCase() === dr.email?.toLowerCase());
    if (!local) continue;
    await db
      .update(documentSigners)
      .set({
        signingUrl: dr.signingUrl ?? null,
        embeddedUrl: dr.embeddedUrl ?? null,
        signingToken: dr.token ?? extractSigningToken(dr.signingUrl ?? null),
      })
      .where(eq(documentSigners.id, local.id));
    if (dr.signingUrl) signingUrls[dr.email] = dr.signingUrl;
  }

  // Promote the local document to `sent` + record the Documenso id so
  // the webhook handler can resolve subsequent events back to this row.
  await db
    .update(documents)
    .set({ status: 'sent', documensoId: documensoDoc.id, updatedAt: new Date() })
    .where(eq(documents.id, docRow.id));

  // Pipeline transition: any of the three doc types going out for
  // signing advances the matching pipeline stage + flips the type's
  // doc-status sub-state to 'sent' so the badge updates immediately.
  // EOI here is the upload-draft path (parity with contract/reservation
  // post-2026-05-22); the template-driven EOI flow stamps from
  // documents.service.ts. No berth-rules trigger here - the rules
  // engine fires on `contract_signed` etc. via the webhook handler.
  // `'generic'` documents skip the pipeline-stage advance + the
  // per-type doc-status flip - they're cross-cutting envelopes that
  // happen to be filed against this interest. The eoi / contract /
  // reservation_agreement branches keep their existing side effects.
  if (documentType !== 'generic' && interestId) {
    const stageByType: Record<
      Exclude<CustomDocumentType, 'generic'>,
      'eoi' | 'contract' | 'reservation'
    > = {
      eoi: 'eoi',
      contract: 'contract',
      reservation_agreement: 'reservation',
    };
    const labelByType: Record<Exclude<CustomDocumentType, 'generic'>, string> = {
      eoi: 'EOI',
      contract: 'Contract',
      reservation_agreement: 'Reservation agreement',
    };
    void advanceStageIfBehind(
      interestId,
      portId,
      stageByType[documentType],
      meta,
      `${labelByType[documentType]} sent for signing`,
    );
    const interestPatch =
      documentType === 'contract'
        ? { contractDocStatus: 'sent' as const, dateContractSent: new Date() }
        : documentType === 'reservation_agreement'
          ? { reservationDocStatus: 'sent' as const }
          : { eoiDocStatus: 'sent' as const, dateEoiSent: new Date() };
    await db
      .update(interests)
      .set({ ...interestPatch, updatedAt: new Date() })
      .where(eq(interests.id, interestId));
  }

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'create',
    entityType: 'document',
    entityId: docRow.id,
    newValue: {
      documentType,
      title,
      documensoId: documensoDoc.id,
      recipientCount: recipients.length,
      fieldCount: fields.length,
    },
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });
  emitToRoom(`port:${portId}`, 'document:sent', {
    documentId: docRow.id,
    type: documentType,
    signerCount: recipients.length,
    documensoId: documensoDoc.id,
  });

  // ─── Auto-send first invitation ──────────────────────────────────
  if (docCfg.sendMode === 'auto') {
    const firstByOrder = [...signerRows].sort((a, b) => a.signingOrder - b.signingOrder)[0];
    if (firstByOrder) {
      // Re-read the row so we get the freshly-written signingUrl.
      const refreshed = await db.query.documentSigners.findFirst({
        where: eq(documentSigners.id, firstByOrder.id),
      });
      if (refreshed?.signingUrl) {
        await sendSigningInvitation({
          portId,
          portName: port.name,
          recipient: { name: refreshed.signerName, email: refreshed.signerEmail },
          documensoSigningUrl: refreshed.signingUrl,
          documentLabel: DOC_TYPE_LABEL[documentType] ?? 'Sales Contract',
          signerRole: (refreshed.signerRole as SignerRole) ?? 'client',
          senderName: docCfg.developerName ?? null,
          customMessage: invitationMessage?.trim() || null,
        }).catch((err) => {
          logger.error(
            { err, documentId: docRow.id, signerId: refreshed.id },
            'Auto-send invitation failed (manual retry via Send button still available)',
          );
        });
        await db
          .update(documentSigners)
          .set({ invitedAt: new Date() })
          .where(eq(documentSigners.id, refreshed.id));
      }
    }
  }

  return {
    documentId: docRow.id,
    documensoDocumentId: documensoDoc.id,
    signingUrls,
  };
}

/**
 * Map Documenso's recipient role enum to our internal signerRole
 * vocabulary (`client | developer | approver | witness | other`).
 *
 * The custom-upload flow doesn't know which role label fits - the rep
 * picks SIGNER/APPROVER/CC in the dialog. We map SIGNER → 'other' (the
 * generic case; matches the email template's neutral copy) UNLESS the
 * recipient is the first signer in order, in which case the dialog
 * defaults to the client (handled at the UI level in Phase 4 - the
 * service stays role-blind).
 */
function documensoRoleToLocal(role: CustomRecipientRole): SignerRole {
  switch (role) {
    case 'APPROVER':
      return 'approver';
    case 'CC':
      return 'other';
    case 'SIGNER':
    default:
      return 'other';
  }
}

// Re-export the client type so callers don't have to import from two
// places when building the field array.
export type { DocumensoFieldPlacement } from '@/lib/services/documenso-client';

// Re-export to silence unused-import lint when the union is consumed
// only indirectly via downstream type inference.
export type { CustomDocumentType as _CustomDocumentType };

// Keep the clients import referenced - used by future enhancements
// that resolve the client name for default recipient prefill.
void clients;

/** Void an envelope upstream when we're rolling back a failed local
 *  insert, swallowing any further upstream error (we've already lost
 *  the original failure and don't want to mask it with a cleanup
 *  exception). */
async function documensoVoidSafe(documensoId: string, portId: string): Promise<void> {
  try {
    await documensoVoid(documensoId, portId);
  } catch (err) {
    logger.warn(
      { err, documensoId, portId },
      'Failed to void Documenso envelope during rollback - admin can clean up manually',
    );
  }
}
