import pRetry, { AbortError } from 'p-retry';

import { env } from '@/lib/env';
import { CodedError } from '@/lib/errors';
import { logger } from '@/lib/logger';
import { getPortDocumensoConfig, type DocumensoApiVersion } from '@/lib/services/port-config';
import { fetchWithTimeout, FetchTimeoutError } from '@/lib/fetch-with-timeout';

interface DocumensoCreds {
  baseUrl: string;
  apiKey: string;
  apiVersion: DocumensoApiVersion;
}

interface ResolvedCreds extends DocumensoCreds {
  /** Provenance of the API key - surfaces in error messages so an
   *  operator can tell at a glance whether a 401 is the env fallback's
   *  stale key vs. a per-port admin entry. */
  apiKeySource: 'port' | 'global' | 'env' | 'default' | 'none';
  apiUrlSource: 'port' | 'global' | 'env' | 'default' | 'none';
}

async function resolveCreds(portId?: string): Promise<ResolvedCreds> {
  // env.DOCUMENSO_API_URL / env.DOCUMENSO_API_KEY are now optional - the
  // canonical config lives in admin settings. Empty fallbacks let the call
  // proceed; if both env + admin are blank, the downstream fetch hits an
  // empty URL and errors with a clear "Documenso not configured" upstream
  // (vs. crashing at type-check or boot).
  if (!portId) {
    return {
      baseUrl: env.DOCUMENSO_API_URL ?? '',
      apiKey: env.DOCUMENSO_API_KEY ?? '',
      apiVersion: env.DOCUMENSO_API_VERSION,
      apiKeySource: env.DOCUMENSO_API_KEY ? 'env' : 'none',
      apiUrlSource: env.DOCUMENSO_API_URL ? 'env' : 'none',
    };
  }
  const cfg = await getPortDocumensoConfig(portId);
  return {
    baseUrl: cfg.apiUrl ?? '',
    apiKey: cfg.apiKey ?? '',
    apiVersion: cfg.apiVersion,
    apiKeySource: cfg.apiKeySource ?? (cfg.apiKey ? 'env' : 'none'),
    apiUrlSource: cfg.apiUrlSource ?? (cfg.apiUrl ? 'env' : 'none'),
  };
}

async function documensoFetchOnce(
  path: string,
  options: RequestInit | undefined,
  portId: string | undefined,
): Promise<unknown> {
  const { baseUrl, apiKey } = await resolveCreds(portId);
  let res: Response;
  try {
    res = await fetchWithTimeout(`${baseUrl}${path}`, {
      ...options,
      headers: {
        Authorization: `Bearer ${apiKey}`,
        'Content-Type': 'application/json',
        ...options?.headers,
      },
    });
  } catch (err) {
    if (err instanceof FetchTimeoutError) {
      // Retry timeouts - transient network issue.
      throw new CodedError('DOCUMENSO_TIMEOUT', {
        internalMessage: `${path} timed out after ${err.timeoutMs}ms`,
      });
    }
    throw err;
  }

  if (!res.ok) {
    const err = await res.text();
    logger.error({ path, status: res.status, err, portId }, 'Documenso API error');
    if (res.status === 401 || res.status === 403) {
      // Auth failures are not retryable - wrong key won't fix itself.
      // Surface the resolver source in the error message so the operator
      // sees "key resolved from env fallback" vs "per-port override" and
      // knows whether to edit the deploy env or the port admin row.
      const { apiKeySource, apiUrlSource } = await resolveCreds(portId);
      throw new AbortError(
        new CodedError('DOCUMENSO_AUTH_FAILURE', {
          internalMessage: `${path} → ${res.status} (api key source: ${apiKeySource}, api url source: ${apiUrlSource}, port: ${portId ?? 'global'})`,
        }),
      );
    }
    if (res.status >= 400 && res.status < 500 && res.status !== 429) {
      // 4xx (other than 429) means we sent something Documenso rejected -
      // retrying won't help. 429 (rate-limit) goes through the retry path
      // with backoff so we politely re-attempt after delay.
      throw new AbortError(
        new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
          internalMessage: `${path} → ${res.status}: ${err}`,
        }),
      );
    }
    // 5xx + 429 → transient, retry.
    throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
      internalMessage: `${path} → ${res.status}: ${err}`,
    });
  }

  return res.json();
}

/**
 * Wraps every Documenso call in p-retry: 3 attempts total (1 + 2 retries)
 * with exponential backoff (1s, 4s) + jitter. AbortError short-circuits
 * for auth failures and 4xx-not-429 - those will never succeed on retry.
 *
 * This recovers the "single connection blip drops the whole signing flow"
 * scenario the audit's services pass flagged.
 */
async function documensoFetch(
  path: string,
  options?: RequestInit,
  portId?: string,
): Promise<unknown> {
  return pRetry(() => documensoFetchOnce(path, options, portId), {
    retries: 2,
    factor: 2,
    minTimeout: 1000,
    randomize: true,
    onFailedAttempt: (ctx) => {
      logger.warn(
        {
          path,
          portId,
          attempt: ctx.attemptNumber,
          retriesLeft: ctx.retriesLeft,
          err: ctx.error.message,
        },
        'Documenso fetch retry',
      );
    },
  });
}

// Documenso 2.x has THREE potential ID fields on responses depending on the
// endpoint:
//   - `envelopeId: string`  - the public 'envelope_xxx' identifier. This is
//     what every downstream endpoint expects (/envelope/update,
//     /envelope/distribute, /envelope/{id}, DELETE etc).
//   - `documentId: number|string` - an alias on some responses.
//   - `id`                  - on /template/use this is the INTERNAL numeric
//     pk (e.g. 17). On other endpoints `id` is sometimes the envelope_xxx
//     string. On v1.13 `id` is the only field and represents the document.
//
// Resolution order: envelopeId (most reliable, v2-only) → documentId →
// id. We coerce to string everywhere downstream. A previous version of
// this normalizer used `documentId ?? id` which picked up the numeric
// internal pk from /template/use, broke envelope/update + envelope/distribute
// with "Invalid envelope ID", and silently failed every title-change +
// distribute on freshly-created envelopes.
function normalizeDocument(raw: unknown): DocumensoDocument {
  const r = (raw ?? {}) as Record<string, unknown>;
  const id = String(r.envelopeId ?? r.documentId ?? r.id ?? '');
  // Documenso v2 also exposes a numeric internal pk (`id`) alongside the
  // envelope_xxx string - webhooks ONLY carry the numeric id, so we
  // surface it separately so the webhook resolver can match by either.
  // For v1 responses `id` IS the (numeric) document id, so this is the
  // same value as `id` above. For v2 with envelopeId set, this captures
  // the internal pk that the webhook payload uses.
  const numericIdRaw = r.id;
  const numericId =
    typeof numericIdRaw === 'number'
      ? String(numericIdRaw)
      : typeof numericIdRaw === 'string' && /^\d+$/.test(numericIdRaw)
        ? numericIdRaw
        : null;
  const status = String(r.status ?? 'PENDING');
  // v1.32+ payloads carry a `Recipient` (capital R) array as a legacy
  // duplicate of `recipients` - fall through to it so we still resolve
  // tokens / URLs when only the legacy field is populated.
  const recipientsRaw =
    (r.recipients as Array<Record<string, unknown>> | undefined) ??
    (r.Recipient as Array<Record<string, unknown>> | undefined) ??
    [];
  const recipients = recipientsRaw.map((rec) => {
    // Coalesce the two field names Documenso has used for rejection
    // reason across versions. Empty string → undefined so consumers
    // can `?? null`-fallback cleanly without re-checking truthiness.
    const rawReason = (rec.rejectionReason ?? rec.declineReason) as string | undefined;
    const rejectionReason =
      typeof rawReason === 'string' && rawReason.trim().length > 0 ? rawReason.trim() : undefined;
    return {
      id: String(rec.recipientId ?? rec.id ?? ''),
      name: String(rec.name ?? ''),
      email: String(rec.email ?? ''),
      role: String(rec.role ?? ''),
      signingOrder: Number(rec.signingOrder ?? 0),
      status: String(rec.signingStatus ?? rec.status ?? 'PENDING'),
      signingUrl: typeof rec.signingUrl === 'string' ? rec.signingUrl : undefined,
      embeddedUrl: typeof rec.embeddedUrl === 'string' ? rec.embeddedUrl : undefined,
      // Per-recipient signing token - required on the v1 Recipient model,
      // present on every v2 envelope-distribute response. Documenso uses
      // it as the URL tail (`/sign/<token>`) so it also matches what we
      // see on subsequent webhook deliveries.
      token: typeof rec.token === 'string' ? rec.token : undefined,
      rejectionReason,
    };
  });
  return { id, numericId, status, recipients };
}

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

export interface DocumensoDocument {
  id: string;
  /** Documenso v2 numeric internal pk. Populated alongside the
   *  envelope_xxx `id` so callers can persist both - webhooks use this
   *  one. Null when the response didn't include a numeric id. */
  numericId: string | null;
  status: string;
  recipients: Array<{
    id: string;
    name: string;
    email: string;
    role: string;
    signingOrder: number;
    status: string;
    signingUrl?: string;
    embeddedUrl?: string;
    /** v1 + v2 recipient token. Used to populate
     *  `document_signers.signing_token` so the webhook handler can
     *  match recipients without leaning on email (which may be reused
     *  across roles). */
    token?: string;
    /** Free-text rejection reason. v2 payloads use `rejectionReason`;
     *  some 1.x payloads use the legacy `declineReason`. Coalesced
     *  here so downstream poll/webhook consumers see one stable field. */
    rejectionReason?: string;
  }>;
}

/**
 * When EMAIL_REDIRECT_TO is set (dev / staging), rewrite every recipient
 * email so Documenso doesn't accidentally email real clients during a
 * data import / migration dry-run.
 *
 * The NAME is left untouched: a "Name" signature field auto-fills from the
 * recipient name and renders into the signed PDF, so any annotation here
 * (we used to append "(was: <email>)") leaks into the document and overlaps
 * the signature. The original email is captured in the createDocument log
 * line instead. In production this env var is unset and recipients flow
 * through unchanged.
 */
function applyRecipientRedirect(recipients: DocumensoRecipient[]): DocumensoRecipient[] {
  if (!env.EMAIL_REDIRECT_TO) return recipients;
  return recipients.map((r) => ({
    ...r,
    email: env.EMAIL_REDIRECT_TO!,
  }));
}

/**
 * Same idea for the template-generate endpoint, which takes a payload
 * shape with recipient email/name nested inside `formValues` (Documenso
 * v1.13) or `recipients` (Documenso 2.x). We rewrite both shapes.
 */
function applyPayloadRedirect(payload: Record<string, unknown>): Record<string, unknown> {
  if (!env.EMAIL_REDIRECT_TO) return payload;
  const out: Record<string, unknown> = { ...payload };
  // 2.x recipient shape — redirect the email only, keep the name clean (it
  // renders into the signed PDF's Name field). See applyRecipientRedirect.
  if (Array.isArray(out.recipients)) {
    out.recipients = (out.recipients as Array<Record<string, unknown>>).map((r) => ({
      ...r,
      email: env.EMAIL_REDIRECT_TO,
    }));
  }
  // v1.13 formValues shape - keys vary per template; key by anything that
  // looks like an email field. The conservative approach: only touch keys
  // that already hold a string and end with `Email` / `email`.
  if (out.formValues && typeof out.formValues === 'object') {
    const fv = { ...(out.formValues as Record<string, unknown>) };
    for (const key of Object.keys(fv)) {
      if (/email$/i.test(key) && typeof fv[key] === 'string') {
        fv[key] = env.EMAIL_REDIRECT_TO;
      }
    }
    out.formValues = fv;
  }
  return out;
}

/**
 * Documenso fires its OWN lifecycle emails for every envelope: each event
 * below defaults to `true` (verified against the v2.13 OpenAPI + the EOI
 * Documenso template's stored meta). The CRM is the SOLE sender of signing
 * comms — branded invitations via `sendSigningInvitation`, plus the
 * completion / "who signed" alert emails — so we disable ALL of Documenso's
 * events at creation time.
 *
 * Without this, the local-fill pathway (which creates fresh envelopes via
 * `createDocument`, unlike the template pathway that inherits the template's
 * all-false `emailSettings`) leaks unbranded "Waiting for others" /
 * "Signing Complete!" emails — sent with the signed PDF attached from the
 * Documenso instance's own account (reply-to sales@) — duplicating ours.
 *
 * The v2 schema marks every key `required` when the object is present, so
 * all nine are listed explicitly.
 */
export const DOCUMENSO_SILENT_EMAIL_SETTINGS = {
  recipientSigningRequest: false,
  recipientRemoved: false,
  recipientSigned: false,
  documentPending: false,
  documentCompleted: false,
  documentDeleted: false,
  ownerDocumentCompleted: false,
  ownerRecipientExpired: false,
  ownerDocumentCreated: false,
} as const;

/**
 * Optional metadata applied to the document on creation. v1 accepts
 * `redirectUrl` and `subject`/`message` on its `/documents` endpoint.
 * v2's `/envelope/create` accepts the same plus `signingOrder` for
 * PARALLEL-vs-SEQUENTIAL signing enforcement. `emailSettings` is always
 * forced to `DOCUMENSO_SILENT_EMAIL_SETTINGS` inside `createDocument`.
 */
export interface CreateDocumentMeta {
  subject?: string;
  message?: string;
  redirectUrl?: string;
  /** v2 only. v1 ignores. */
  signingOrder?: 'PARALLEL' | 'SEQUENTIAL';
}

export async function createDocument(
  title: string,
  pdfBase64: string,
  recipients: DocumensoRecipient[],
  portId?: string,
  meta?: CreateDocumentMeta,
): Promise<DocumensoDocument> {
  // Documenso's API requires UPPERCASE recipient roles
  // (CC | SIGNER | VIEWER | APPROVER | ASSISTANT). The CRM uses lowercase
  // role strings internally ('signer' / 'approver'), so normalize here at the
  // API boundary — otherwise create fails with a 400 "Invalid enum value".
  const safeRecipients = applyRecipientRedirect(recipients).map((r) => ({
    ...r,
    role: typeof r.role === 'string' ? r.role.toUpperCase() : r.role,
  }));
  if (env.EMAIL_REDIRECT_TO) {
    logger.info(
      { redirected: safeRecipients.length, original: recipients.map((r) => r.email) },
      'Documenso recipients redirected to EMAIL_REDIRECT_TO',
    );
  }
  const { apiVersion } = await resolveCreds(portId);

  if (apiVersion === 'v2') {
    // v2: multipart /envelope/create with payload + files. Convert the
    // base64 PDF to a Buffer and ship it under `files`. Returns
    // `{ id: envelopeId }` only - caller distributes separately via
    // sendDocument(envelopeId).
    const { baseUrl, apiKey } = await resolveCreds(portId);
    const pdfBuffer = Buffer.from(pdfBase64, 'base64');
    const form = new FormData();
    const payload = {
      type: 'DOCUMENT',
      title,
      recipients: safeRecipients.map((r, i) => ({
        email: r.email,
        name: r.name,
        role: r.role,
        signingOrder: r.signingOrder || i + 1,
      })),
      meta: {
        // CRM is the sole email sender — Documenso stays silent.
        emailSettings: DOCUMENSO_SILENT_EMAIL_SETTINGS,
        ...(meta?.subject ? { subject: meta.subject } : {}),
        ...(meta?.message ? { message: meta.message } : {}),
        ...(meta?.redirectUrl ? { redirectUrl: meta.redirectUrl } : {}),
        ...(meta?.signingOrder ? { signingOrder: meta.signingOrder } : {}),
      },
    };
    form.append('payload', JSON.stringify(payload));
    form.append(
      'files',
      new Blob([pdfBuffer], { type: 'application/pdf' }),
      `${title.replace(/[^a-z0-9-_]+/gi, '-')}.pdf`,
    );

    let res: Response;
    try {
      res = await fetchWithTimeout(`${baseUrl}/api/v2/envelope/create`, {
        method: 'POST',
        headers: { Authorization: `Bearer ${apiKey}` },
        body: form,
      });
    } catch (err) {
      if (err instanceof FetchTimeoutError) {
        throw new CodedError('DOCUMENSO_TIMEOUT', {
          internalMessage: `/api/v2/envelope/create timed out after ${err.timeoutMs}ms`,
        });
      }
      throw err;
    }
    if (!res.ok) {
      const errText = await res.text();
      logger.error(
        { status: res.status, err: errText, portId },
        'Documenso v2 envelope/create error',
      );
      if (res.status === 401 || res.status === 403) {
        throw new CodedError('DOCUMENSO_AUTH_FAILURE', {
          internalMessage: `v2 envelope/create → ${res.status}`,
        });
      }
      throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
        internalMessage: `v2 envelope/create → ${res.status}: ${errText}`,
      });
    }
    const created = (await res.json()) as Record<string, unknown>;
    // v2 returns just `{ id }`. Re-fetch the full envelope so the
    // caller gets recipients (without signing URLs - those come after
    // distribute). Keeps shape identical to v1's createDocument response.
    const envelopeId = String(created.id ?? created.documentId ?? '');
    return getDocument(envelopeId, portId);
  }

  // v1: existing path. Meta keys are accepted at the top level. We still send
  // `document` (base64) for older Documenso servers that store it inline, but
  // Documenso 2.x's v1-compat endpoint instead returns a presigned `uploadUrl`
  // and expects the PDF bytes to be PUT there (the base64 is ignored). So when
  // the create response carries an `uploadUrl`, upload the bytes to it — without
  // this the document is created with NO content (signers see a blank PDF).
  const raw = (await documensoFetch(
    '/api/v1/documents',
    {
      method: 'POST',
      body: JSON.stringify({
        title,
        document: pdfBase64,
        recipients: safeRecipients,
        meta: {
          // CRM is the sole email sender — Documenso stays silent.
          emailSettings: DOCUMENSO_SILENT_EMAIL_SETTINGS,
          ...(meta?.subject ? { subject: meta.subject } : {}),
          ...(meta?.message ? { message: meta.message } : {}),
          ...(meta?.redirectUrl ? { redirectUrl: meta.redirectUrl } : {}),
        },
      }),
    },
    portId,
  )) as Record<string, unknown>;

  const uploadUrl = typeof raw.uploadUrl === 'string' ? raw.uploadUrl : null;
  if (uploadUrl) {
    const pdfBuffer = Buffer.from(pdfBase64, 'base64');
    let putRes: Response;
    try {
      putRes = await fetchWithTimeout(uploadUrl, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/pdf' },
        body: pdfBuffer,
      });
    } catch (err) {
      if (err instanceof FetchTimeoutError) {
        throw new CodedError('DOCUMENSO_TIMEOUT', {
          internalMessage: `v1 createDocument uploadUrl PUT timed out after ${err.timeoutMs}ms`,
        });
      }
      throw err;
    }
    if (!putRes.ok) {
      const errText = await putRes.text().catch(() => '');
      logger.error(
        { status: putRes.status, err: errText, portId },
        'Documenso v1 createDocument uploadUrl PUT failed - document has no content',
      );
      throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
        internalMessage: `v1 createDocument uploadUrl PUT → ${putRes.status}: ${errText}`,
      });
    }
  }

  return normalizeDocument(raw);
}

export async function generateDocumentFromTemplate(
  templateId: number,
  payload: Record<string, unknown>,
  portId?: string,
): Promise<DocumensoDocument> {
  const safePayload = applyPayloadRedirect(payload);
  const { apiVersion } = await resolveCreds(portId);
  if (env.EMAIL_REDIRECT_TO) {
    logger.info(
      { templateId, apiVersion },
      'Documenso template-generate payload redirected to EMAIL_REDIRECT_TO',
    );
  }

  // v2 uses POST /api/v2/template/use with `prefillFields` keyed by field ID.
  // The payload builder emits `prefillFields` when a cached field name→ID map
  // exists for the port - see `buildDocumensoPayload` + `documenso-template-
  // sync.service.ts`. When no map is cached we still hit /template/use but
  // skip prefillFields (recipients-only); v2 instances ignore the legacy
  // `formValues` field, so emit it only on v1 paths.
  //
  // v1 (incl. Documenso 1.13.x) uses the legacy
  // /api/v1/templates/{id}/generate-document with `formValues` by name.
  if (apiVersion === 'v2') {
    const v2Payload = safePayload as Record<string, unknown>;

    // Title PATCH must happen BEFORE distribution because Documenso v2
    // restricts `envelope/update` to DRAFT envelopes only. So the v2
    // flow is: 1) /template/use without distribute → DRAFT envelope, 2)
    // /envelope/update with the title, 3) /envelope/distribute → PENDING
    // envelope with signingUrls populated. Step 3 is REQUIRED because
    // v2 doesn't return signingUrls from /template/use - without it
    // `document_signers.signing_url` stays null and the manual
    // "Send invitation" button errors with "Signer has no Documenso URL".
    const created = await documensoFetch(
      `/api/v2/template/use`,
      {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ ...v2Payload, templateId }),
      },
      portId,
    ).then(normalizeDocument);

    // Resolve an authoritative numeric pk. `/template/use` returns the
    // `envelope_xxx` string under `id`/`envelopeId` but does NOT reliably
    // surface the internal numeric pk, so `created.numericId` is frequently
    // null. DOCUMENT_COMPLETED (and other v2 webhooks) carry ONLY that
    // numeric pk as `payload.id`, and `resolveWebhookDocument` matches it
    // against `documents.documenso_numeric_id`. Persisting null there means
    // the webhook resolves against neither column and the completion is
    // dropped (signed PDF never downloads, stage never advances, no
    // completion email/tenancy) until the poll worker reconciles by
    // envelope id. Re-fetch the full envelope (GET /api/v2/envelope/{id})
    // when the numeric pk is missing so we persist a non-null value.
    let numericId = created.numericId;
    if (!numericId) {
      try {
        const fetched = await getDocument(created.id, portId);
        if (fetched.numericId) numericId = fetched.numericId;
      } catch (err) {
        logger.warn(
          { docId: created.id, err: err instanceof Error ? err.message : err },
          'Documenso envelope re-fetch for numericId failed - documenso_numeric_id will be null; ' +
            'completion webhooks rely on the poll-worker reconciliation until then.',
        );
      }
    }

    const desiredTitle =
      typeof v2Payload.title === 'string' && v2Payload.title.length > 0 ? v2Payload.title : null;
    // `/template/use` silently drops the `meta` field on the request body -
    // signingOrder, subject, message, redirectUrl all inherit from the
    // template's stored defaults. To enforce the per-port `documenso_signing_
    // order` (PARALLEL vs SEQUENTIAL) and per-port subject/message, replay
    // the meta fields through `/envelope/update` while the envelope is still
    // DRAFT (update is rejected once distributed).
    const payloadMeta = (v2Payload.meta as Record<string, unknown> | undefined) ?? {};
    const updateMeta: Record<string, unknown> = {};
    for (const key of ['signingOrder', 'subject', 'message', 'redirectUrl', 'language'] as const) {
      const value = payloadMeta[key];
      if (value !== undefined && value !== null && value !== '') {
        updateMeta[key] = value;
      }
    }
    const hasMetaPatch = Object.keys(updateMeta).length > 0;
    if (desiredTitle || hasMetaPatch) {
      try {
        const updateBody: Record<string, unknown> = { envelopeId: created.id };
        if (desiredTitle) updateBody.data = { title: desiredTitle };
        if (hasMetaPatch) updateBody.meta = updateMeta;
        const updateResponse = await documensoFetch(
          `/api/v2/envelope/update`,
          {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(updateBody),
          },
          portId,
        );
        // Log the raw response so we can debug when Documenso's UI keeps
        // showing the template PDF filename despite our update succeeding.
        // The update endpoint returns `{success: true}` on a clean ack;
        // anything else hints that the title field wasn't accepted.
        logger.info(
          { docId: created.id, desiredTitle, updateMeta, updateResponse },
          'Documenso envelope update - response',
        );
        // Belt-and-braces verify: re-read the envelope and confirm the
        // title persisted. Documenso v2's listing surface has been known
        // to render the underlying PDF filename rather than envelope.title
        // - surfacing the actual returned `title` here lets us tell
        // whether the API accepted our value (and the UI is the issue)
        // vs the update silently no-op'd.
        try {
          const verify = (await documensoFetch(
            `/api/v2/envelope/${created.id}`,
            { method: 'GET' },
            portId,
          )) as Record<string, unknown>;
          logger.info(
            {
              docId: created.id,
              desiredTitle,
              actualTitle: verify?.title,
              titleMatches: verify?.title === desiredTitle,
              actualMeta: verify?.documentMeta ?? verify?.envelopeMeta ?? verify?.meta,
            },
            'Documenso envelope update - verification',
          );
        } catch {
          // GET verify is best-effort; don't fail generate on it.
        }
      } catch (err) {
        logger.warn(
          { docId: created.id, updateMeta, err: err instanceof Error ? err.message : err },
          'Documenso envelope update failed - created envelope keeps template default title/meta',
        );
      }
    }

    // Distribute the envelope so per-recipient signing URLs are minted.
    // Without this, the recipients returned by /template/use have
    // `signingUrl: null` and our "Send invitation" button errors out
    // with "Signer has no Documenso URL yet."
    //
    // Documenso v2's distribute fires its own emails by default, but
    // our payload sets `meta.distributionMethod: 'NONE'` so it just
    // mints the URLs without emailing - our branded
    // `sendSigningInvitation` is the dispatcher.
    //
    // We replace `created` with the distribute response because that's
    // the call that actually returns recipients with `signingUrl`
    // populated; downstream code (the document_signers insert in
    // generateAndSignViaDocumensoTemplate) reads from this object.
    // CRITICAL: pass `meta.distributionMethod: 'NONE'` in the distribute
    // body. `/template/use` doesn't accept a `meta` field at all - our
    // payload's `meta.distributionMethod: 'NONE'` is silently dropped at
    // template-use time, so the envelope inherits the TEMPLATE's
    // distributionMethod (which defaults to EMAIL). Without overriding
    // it on the distribute call, Documenso fires its own emails the
    // moment distribute runs - which clashes with our branded
    // `sendSigningInvitation` flow and ignores the per-port
    // `eoi_send_mode: 'manual'` setting. The override here is the
    // authoritative one for v2 envelopes.
    try {
      const distributed = (await documensoFetch(
        `/api/v2/envelope/distribute`,
        {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            envelopeId: created.id,
            meta: { distributionMethod: 'NONE' },
          }),
        },
        portId,
      )) as Record<string, unknown>;
      const normalized = normalizeDocument({
        envelopeId: distributed.id ?? created.id,
        // Distribute doesn't return the numeric id, so we synthesize it
        // from the authoritative numericId resolved above (created response
        // or envelope re-fetch) by passing it as Documenso's `id` field -
        // normalizeDocument picks it up as numericId. Without this, the row
        // would lose its numeric id on distribute and webhooks couldn't
        // resolve back to it.
        id: numericId,
        status: 'PENDING',
        recipients: distributed.recipients,
      });
      return normalized;
    } catch (err) {
      logger.warn(
        { docId: created.id, err: err instanceof Error ? err.message : err },
        'Documenso envelope distribute failed - signingUrl will be null. Send-invitation will fail until the envelope is distributed.',
      );
      // Preserve the authoritative numericId resolved above so the persisted
      // documenso_numeric_id is non-null even when distribute fails.
      return { ...created, numericId };
    }
  }

  return documensoFetch(
    `/api/v1/templates/${templateId}/generate-document`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(safePayload),
    },
    portId,
  ).then(normalizeDocument);
}

/**
 * Tell Documenso to actually email the document to its recipients. The
 * recipients themselves are set at create-time (and rerouted to
 * EMAIL_REDIRECT_TO when set), but this is a belt-and-braces guard for
 * documents that may have been created BEFORE the redirect was turned on
 * (i.e. real-recipient documents now triggered by an automation while
 * we're trying to hold comms). When the redirect is on we skip the API
 * call entirely and return a synthetic "still pending" response.
 */
/**
 * v2-only: distribute an envelope. Moves it from DRAFT → PENDING and
 * mints per-recipient signing URLs. Does NOT email recipients when the
 * envelope's `meta.distributionMethod` is `NONE` (our default - branded
 * emails are dispatched by `sendSigningInvitation`).
 *
 * Direct call bypassing `sendDocument`'s dev-mode short-circuit. The
 * self-heal path for envelopes created before the auto-distribute fix
 * shipped uses this so the URLs actually get minted in dev too -
 * `EMAIL_REDIRECT_TO` already rewrites recipient emails to a safe
 * address at envelope-creation time, so distribute can't accidentally
 * email a real client.
 */
export async function distributeEnvelopeV2(
  envelopeId: string,
  portId?: string,
): Promise<DocumensoDocument> {
  // Architectural rule (Matt 2026-05-15): ALL outbound emails go through
  // our branded `sendSigningInvitation` path - Documenso never fires its
  // own emails for our envelopes. `meta.distributionMethod: 'NONE'`
  // here is the ONLY place where this contract is actually enforced
  // for v2 envelopes (the corresponding flag in /template/use is
  // silently dropped because that endpoint doesn't accept a meta field).
  const distributed = (await documensoFetch(
    `/api/v2/envelope/distribute`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        envelopeId,
        meta: { distributionMethod: 'NONE' },
      }),
    },
    portId,
  )) as Record<string, unknown>;
  return normalizeDocument({
    id: distributed.id ?? envelopeId,
    status: 'PENDING',
    recipients: distributed.recipients,
  });
}

export async function sendDocument(docId: string, portId?: string): Promise<DocumensoDocument> {
  if (env.EMAIL_REDIRECT_TO) {
    logger.warn(
      { docId, portId, redirect: env.EMAIL_REDIRECT_TO },
      'sendDocument SKIPPED - EMAIL_REDIRECT_TO is set, outbound comms paused',
    );
    // Return the existing doc shape so downstream code doesn't see an
    // unexpected null. The document remains in DRAFT/PENDING from
    // Documenso's perspective.
    return getDocument(docId, portId);
  }
  const { apiVersion } = await resolveCreds(portId);

  if (apiVersion === 'v2') {
    // v2: POST /api/v2/envelope/distribute with body { envelopeId }.
    // Returns the envelope with per-recipient signingUrl fields populated -
    // this is one of the genuine v2 wins (saves a separate GET round-trip).
    // `meta.distributionMethod: 'NONE'` is the authoritative way to suppress
    // Documenso's own emails for v2 envelopes - see distributeEnvelopeV2
    // for the full rationale. Branded sends are routed through
    // `sendSigningInvitation` separately.
    const distributed = (await documensoFetch(
      '/api/v2/envelope/distribute',
      {
        method: 'POST',
        body: JSON.stringify({
          envelopeId: docId,
          meta: { distributionMethod: 'NONE' },
        }),
      },
      portId,
    )) as Record<string, unknown>;
    // Distribute response shape: { success, id, recipients: [...] }.
    // The recipients carry name/email/token/role/signingOrder/signingUrl.
    // Normalize by re-wrapping into the document shape that downstream
    // callers already consume.
    return normalizeDocument({
      id: distributed.id,
      // v2 doesn't return `status` on the distribute response - the call
      // itself moves the envelope from DRAFT to PENDING, so PENDING is
      // the correct authoritative state.
      status: 'PENDING',
      recipients: distributed.recipients,
    });
  }

  return documensoFetch(
    `/api/v1/documents/${docId}/send`,
    {
      method: 'POST',
    },
    portId,
  ).then(normalizeDocument);
}

export async function getDocument(docId: string, portId?: string): Promise<DocumensoDocument> {
  const { apiVersion } = await resolveCreds(portId);
  // v1: GET /api/v1/documents/{id}
  // v2: GET /api/v2/envelope/{id} - same response normalizer (id ↔ documentId,
  //      recipientId ↔ id handled by normalizeDocument).
  const path = apiVersion === 'v2' ? `/api/v2/envelope/${docId}` : `/api/v1/documents/${docId}`;
  return documensoFetch(path, undefined, portId).then(normalizeDocument);
}

// ─── Template introspection ─────────────────────────────────────────────────

export interface DocumensoTemplateRecipient {
  id: number;
  role: string; // 'SIGNER' | 'APPROVER' | 'CC' | 'VIEWER'
  signingOrder: number;
  name?: string;
  email?: string;
}

export interface DocumensoTemplateField {
  id: number;
  type: string;
  /**
   * The human label assigned in the template editor - for v2 templates this
   * comes from `field.fieldMeta.label`; for v1 templates it's available as
   * `field.fieldMeta.label` too (the shape was preserved). Used as the key
   * for the cached field-name → ID map that drives v2's `prefillFields`.
   */
  label?: string;
}

export interface DocumensoTemplate {
  id: number;
  title: string;
  recipients: DocumensoTemplateRecipient[];
  fields: DocumensoTemplateField[];
  /**
   * v2 only. Each entry corresponds to one underlying PDF file on the
   * template - usually a single envelope item per template, but Documenso
   * 2.x supports stitching multiple PDFs together. Used by the sync flow
   * to download each PDF and inspect its native AcroForm fields.
   */
  envelopeItems: Array<{ id: string }>;
  /**
   * v2 only. The template's stored meta - signing order, distribution
   * method, redirect URL. Surfaced in the sync report so the admin can
   * confirm what the template itself is configured to do at envelope
   * creation time. /template/use does NOT accept a signingOrder override,
   * so these values are what every generated envelope inherits.
   */
  meta: {
    signingOrder: 'PARALLEL' | 'SEQUENTIAL' | null;
    distributionMethod: 'EMAIL' | 'NONE' | null;
    redirectUrl: string | null;
  };
}

function normalizeTemplate(raw: unknown): DocumensoTemplate {
  const r = (raw ?? {}) as Record<string, unknown>;
  const id = Number(r.templateId ?? r.id ?? 0);
  const title = String(r.title ?? '');
  const recipientsRaw =
    (r.recipients as Array<Record<string, unknown>> | undefined) ??
    (r.Recipient as Array<Record<string, unknown>> | undefined) ??
    [];
  const recipients: DocumensoTemplateRecipient[] = recipientsRaw.map((rec) => ({
    id: Number(rec.recipientId ?? rec.id ?? 0),
    role: String(rec.role ?? 'SIGNER'),
    signingOrder: Number(rec.signingOrder ?? 0),
    name: typeof rec.name === 'string' ? rec.name : undefined,
    email: typeof rec.email === 'string' ? rec.email : undefined,
  }));
  const fieldsRaw = (r.fields as Array<Record<string, unknown>> | undefined) ?? [];
  const fields: DocumensoTemplateField[] = fieldsRaw.map((f) => {
    const fieldMeta = (f.fieldMeta as Record<string, unknown> | undefined) ?? {};
    return {
      id: Number(f.id ?? 0),
      type: String(f.type ?? ''),
      label: typeof fieldMeta.label === 'string' ? fieldMeta.label : undefined,
    };
  });
  const itemsRaw = (r.envelopeItems as Array<Record<string, unknown>> | undefined) ?? [];
  const envelopeItems = itemsRaw
    .map((it) => ({ id: typeof it.id === 'string' ? it.id : '' }))
    .filter((it) => it.id);

  // templateMeta on v2 carries the signing order + distribution method +
  // post-sign redirect. v1 templates put the same data on the doc root, so
  // try both shapes.
  const metaRaw =
    (r.templateMeta as Record<string, unknown> | undefined) ?? (r as Record<string, unknown>);
  const signingOrderRaw = metaRaw.signingOrder;
  const distributionRaw = metaRaw.distributionMethod;
  const redirectRaw = metaRaw.redirectUrl;
  const meta: DocumensoTemplate['meta'] = {
    signingOrder:
      signingOrderRaw === 'PARALLEL' || signingOrderRaw === 'SEQUENTIAL' ? signingOrderRaw : null,
    distributionMethod:
      distributionRaw === 'EMAIL' || distributionRaw === 'NONE' ? distributionRaw : null,
    redirectUrl: typeof redirectRaw === 'string' && redirectRaw ? redirectRaw : null,
  };

  return { id, title, recipients, fields, envelopeItems, meta };
}

/**
 * v2-only: download the raw PDF bytes for one envelope-item (each template
 * is backed by 1+ envelope items, one per uploaded PDF). The sync flow
 * uses this to inspect the PDF's AcroForm field names, surfacing whether
 * the operator's fillable PDF matches the CRM's expected field-label set.
 *
 * v1 templates aren't supported here - the v1 download endpoint requires
 * a documentId, not a templateId, and v1 doesn't expose envelope items.
 */
export async function downloadEnvelopeItemPdf(
  envelopeItemId: string,
  portId?: string,
  version: 'signed' | 'original' = 'original',
): Promise<Buffer> {
  const { baseUrl, apiKey } = await resolveCreds(portId);
  const res = await fetchWithTimeout(
    `${baseUrl}/api/v2/envelope/item/${envelopeItemId}/download?version=${version}`,
    { headers: { Authorization: `Bearer ${apiKey}` } },
  );
  if (!res.ok) {
    const errText = await res.text().catch(() => '');
    throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
      internalMessage: `download envelope item ${envelopeItemId} → ${res.status} ${errText}`,
    });
  }
  return Buffer.from(await res.arrayBuffer());
}

/**
 * List every template visible to the configured API key. Used by the
 * admin "Documenso-first templates" picker (R62) so reps can browse
 * available templates instead of typing numeric IDs.
 *
 * v2 path: paginated `GET /api/v2/template`. Returns 100 per page; we
 * walk through pages until empty (cap at 5 pages = 500 templates which
 * comfortably covers every observed Documenso instance).
 *
 * v1 path: `GET /api/v1/templates`. Same pagination via
 * `?page=N&perPage=100`. v1 returns the legacy shape - we normalize to
 * the same `{ id, name }` summary the UI consumes.
 */
export async function listTemplates(
  portId: string | undefined,
): Promise<Array<{ id: number; name: string }>> {
  const { apiVersion } = await resolveCreds(portId);
  const out: Array<{ id: number; name: string }> = [];
  for (let page = 1; page <= 5; page++) {
    const path =
      apiVersion === 'v2'
        ? `/api/v2/template?perPage=100&page=${page}`
        : `/api/v1/templates?perPage=100&page=${page}`;
    const res = (await documensoFetch(path, undefined, portId)) as {
      templates?: Array<{ id?: number; templateId?: number; name?: string; title?: string }>;
      data?: Array<{ id?: number; templateId?: number; name?: string; title?: string }>;
    };
    const items = res.templates ?? res.data ?? [];
    if (items.length === 0) break;
    for (const t of items) {
      const id = t.templateId ?? t.id;
      const name = t.name ?? t.title ?? '';
      if (typeof id === 'number') out.push({ id, name });
    }
    if (items.length < 100) break;
  }
  return out;
}

/**
 * Fetch a Documenso template by ID. Used by the admin "Sync from Documenso"
 * flow to discover recipient slot IDs + template field IDs without forcing
 * the operator to type them in by hand.
 *
 * - v2 path: `GET /api/v2/template/{templateId}` (returns envelope-shape JSON)
 * - v1 path: `GET /api/v1/templates/{templateId}` (returns legacy doc shape;
 *            recipient + field arrays are present but with `id` instead of
 *            `recipientId`/`templateId`).
 */
export async function getTemplate(templateId: number, portId?: string): Promise<DocumensoTemplate> {
  const { apiVersion } = await resolveCreds(portId);
  const path =
    apiVersion === 'v2' ? `/api/v2/template/${templateId}` : `/api/v1/templates/${templateId}`;
  return documensoFetch(path, undefined, portId).then(normalizeTemplate);
}

/**
 * v2-only: resolve a Documenso template by its envelope ID (the
 * `envelope_xxxxxxxx` string that appears in the Documenso UI URL). The
 * admin pastes that URL slug into the Sync input and we look up the
 * matching numeric template id via `GET /api/v2/template`. Returns null
 * when no template matches.
 *
 * Documenso 2.x's template editor URL is
 * `https://.../templates/envelope_xxxxxxxx`, but the numeric `id` is not
 * surfaced anywhere in the UI - so admins have no way to enter the
 * numeric id by hand. This resolver bridges the gap.
 */
export async function findTemplateIdByEnvelopeId(
  envelopeId: string,
  portId?: string,
): Promise<number | null> {
  const { apiVersion } = await resolveCreds(portId);
  if (apiVersion !== 'v2') return null;
  // Paginate through templates (perPage maxes at ~100 on the upstream).
  // Most installs have <50 templates so the first page is usually enough.
  let page = 1;
  const perPage = 100;
  while (page < 20) {
    const res = (await documensoFetch(
      `/api/v2/template?page=${page}&perPage=${perPage}`,
      undefined,
      portId,
    )) as { data?: Array<Record<string, unknown>>; templates?: Array<Record<string, unknown>> };
    const rows = res.data ?? res.templates ?? [];
    if (!Array.isArray(rows) || rows.length === 0) return null;
    for (const row of rows) {
      const rowEnvelopeId = String(row.envelopeId ?? '');
      if (rowEnvelopeId === envelopeId) {
        const numericId = Number(row.id ?? 0);
        return Number.isInteger(numericId) && numericId > 0 ? numericId : null;
      }
    }
    if (rows.length < perPage) return null;
    page += 1;
  }
  return null;
}

/**
 * Email a signing reminder to one recipient. Skipped entirely when
 * EMAIL_REDIRECT_TO is set - the recipient's stored email may still be
 * a real client address from before the redirect was enabled.
 */
export async function sendReminder(
  docId: string,
  signerId: string,
  portId?: string,
): Promise<void> {
  if (env.EMAIL_REDIRECT_TO) {
    logger.warn(
      { docId, signerId, portId, redirect: env.EMAIL_REDIRECT_TO },
      'sendReminder SKIPPED - EMAIL_REDIRECT_TO is set, outbound comms paused',
    );
    return;
  }
  const { apiVersion } = await resolveCreds(portId);

  if (apiVersion === 'v2') {
    // v2 sends reminders via redistribute. Documenso 2.x doesn't expose a
    // recipient-targeted reminder endpoint directly; instead /envelope/redistribute
    // resends to all pending recipients on the envelope. Single-recipient
    // targeting requires admin-side filtering. For now we redistribute the
    // entire envelope, which is functionally equivalent for the typical
    // case (most reminders go to the one outstanding signer).
    await documensoFetch(
      '/api/v2/envelope/redistribute',
      {
        method: 'POST',
        body: JSON.stringify({ envelopeId: docId, recipientIds: [signerId] }),
      },
      portId,
    );
    return;
  }
  await documensoFetch(
    `/api/v1/documents/${docId}/recipients/${signerId}/remind`,
    {
      method: 'POST',
    },
    portId,
  );
}

export async function downloadSignedPdf(docId: string, portId?: string): Promise<Buffer> {
  const { baseUrl, apiKey, apiVersion } = await resolveCreds(portId);
  // v2 download is a two-step lookup: there's no /envelope/{id}/download path
  // (that 404s - see audit-2026-05-15). The canonical flow is:
  //   1. GET /envelope/{id} → read envelopeItems[0].id
  //   2. GET /envelope/item/{itemId}/download?version=signed
  // v1 keeps the direct /documents/{id}/download single-call path.
  if (apiVersion === 'v2') {
    const envelope = (await documensoFetch(
      `/api/v2/envelope/${docId}`,
      { method: 'GET' },
      portId,
    )) as { envelopeItems?: Array<{ id?: string }> };
    const itemId = envelope.envelopeItems?.[0]?.id;
    if (!itemId) {
      throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
        internalMessage: `v2 envelope ${docId} has no envelopeItems - cannot download signed PDF`,
      });
    }
    return downloadEnvelopeItemPdf(itemId, portId, 'signed');
  }
  const path = `/api/v1/documents/${docId}/download`;
  let res: Response;
  try {
    res = await fetchWithTimeout(`${baseUrl}${path}`, {
      headers: { Authorization: `Bearer ${apiKey}` },
    });
  } catch (err) {
    if (err instanceof FetchTimeoutError) {
      throw new CodedError('DOCUMENSO_TIMEOUT', {
        internalMessage: `${path} timed out after ${err.timeoutMs}ms`,
      });
    }
    throw err;
  }

  if (!res.ok) {
    const err = await res.text();
    logger.error({ docId, status: res.status, err, portId }, 'Documenso download error');
    if (res.status === 401 || res.status === 403) {
      throw new CodedError('DOCUMENSO_AUTH_FAILURE', {
        internalMessage: `${path} → ${res.status}`,
      });
    }
    throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
      internalMessage: `${path} → ${res.status}: ${err}`,
    });
  }

  // Documenso 2.13's v1-compat `/download` returns JSON `{ downloadUrl }`
  // (a presigned S3 URL), NOT the raw PDF. Older v1 returned the PDF bytes
  // directly. Detect by magic bytes: a real PDF starts with `%PDF-`. When it
  // doesn't, parse the JSON and follow `downloadUrl` to fetch the actual file.
  // Saving the JSON body as the "signed PDF" produced a ~500-byte corrupt file
  // that got emailed to every signer + filed in the CRM (audit 2026-06-24).
  const firstBuf = Buffer.from(await res.arrayBuffer());
  if (firstBuf.subarray(0, 5).toString('latin1') === '%PDF-') {
    return firstBuf;
  }

  let downloadUrl: string | undefined;
  try {
    downloadUrl = (JSON.parse(firstBuf.toString('utf8')) as { downloadUrl?: string }).downloadUrl;
  } catch {
    /* body was neither a PDF nor JSON */
  }
  if (!downloadUrl) {
    throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
      internalMessage: `${path} returned a ${firstBuf.length}-byte non-PDF body with no downloadUrl`,
    });
  }

  let pdfRes: Response;
  try {
    pdfRes = await fetchWithTimeout(downloadUrl, {});
  } catch (err) {
    if (err instanceof FetchTimeoutError) {
      throw new CodedError('DOCUMENSO_TIMEOUT', {
        internalMessage: `signed-PDF presigned download timed out after ${err.timeoutMs}ms`,
      });
    }
    throw err;
  }
  if (!pdfRes.ok) {
    throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
      internalMessage: `signed-PDF presigned URL → ${pdfRes.status}`,
    });
  }
  const pdfBuf = Buffer.from(await pdfRes.arrayBuffer());
  if (pdfBuf.subarray(0, 5).toString('latin1') !== '%PDF-') {
    throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
      internalMessage: `signed-PDF presigned URL returned a ${pdfBuf.length}-byte non-PDF`,
    });
  }
  return pdfBuf;
}

/** Convenience health-check used by the admin "Test connection" button.
 *
 * v2 cloud (Documenso 2.x) doesn't expose `/api/v1/health` - the old v1
 * path is gone. So we probe the appropriate cheap list endpoint per
 * version (`GET /api/v2/document` for v2, `GET /api/v1/health` for v1)
 * and treat a 401 as "creds rejected" and a 200 as "all good". A 404
 * means the URL points at something that isn't Documenso. */
export async function checkDocumensoHealth(
  portId?: string,
): Promise<{ ok: boolean; status?: number; error?: string; apiVersion?: DocumensoApiVersion }> {
  try {
    const { baseUrl, apiKey, apiVersion } = await resolveCreds(portId);
    const path = apiVersion === 'v2' ? '/api/v2/document' : '/api/v1/health';
    const res = await fetchWithTimeout(`${baseUrl}${path}`, {
      headers: { Authorization: `Bearer ${apiKey}` },
    });
    // 2xx = full success; 401/403 = creds wrong but URL right (still a
    // partial-success signal - the admin should know it's an auth issue,
    // not a typoed URL). 404 = wrong URL.
    return { ok: res.ok, status: res.status, apiVersion };
  } catch (err) {
    return { ok: false, error: err instanceof Error ? err.message : 'Unknown error' };
  }
}

// ─── Version-aware abstractions (Phase A PR2) ─────────────────────────────────
//
// Documenso v1.13 and v2.x diverge on field placement and document deletion:
//
//   v1.13: per-field POST /api/v1/documents/{id}/fields with PIXEL coords;
//          DELETE /api/v1/documents/{id} for void.
//   v2.x:  bulk   POST /api/v2/envelope/field/create-many with PERCENT
//          coords (0-100) and rich `fieldMeta`;
//          DELETE /api/v2/envelope/{id} for void.
//
// Callers always work in PERCENT (0-100). For v1 the abstraction multiplies by
// the page dimensions returned by Documenso (cached per docId for the lifetime
// of the process - fields for a given doc usually go in a single batch).

/**
 * Every field type Documenso supports across v1 and v2. The earlier
 * subset (SIGNATURE/INITIALS/DATE/TEXT/EMAIL) covered the EOI flow's
 * needs but locks out custom-uploaded contracts/reservations that
 * may need checkboxes (e.g. "Lease vs Purchase"), dropdowns (e.g.
 * "Berth class A/B/C"), or radio groups. Extending now so the
 * field-placement UI can surface the full palette without later
 * widening this type and patching every call site.
 *
 * Per-type fieldMeta expectations (passed through verbatim):
 *   - SIGNATURE / FREE_SIGNATURE / INITIALS / DATE / EMAIL / NAME - no meta
 *   - TEXT      - { text?: string, label?: string, required?: bool, readOnly?: bool }
 *   - NUMBER    - { numberFormat?: string, min?: number, max?: number, required?: bool }
 *   - CHECKBOX  - { values: Array<{ checked: bool, value: string }>, validationRule?: string }
 *   - DROPDOWN  - { values: Array<{ value: string }>, defaultValue?: string }
 *   - RADIO     - { values: Array<{ checked: bool, value: string }> }
 *
 * `fieldMeta` is sent verbatim to v2's create-many endpoint and
 * silently ignored by v1 (which doesn't accept the property). v1
 * rendering of TEXT/NUMBER/CHECKBOX/DROPDOWN/RADIO falls back to
 * blank-input behaviour without the meta.
 */
export type DocumensoFieldType =
  | 'SIGNATURE'
  | 'FREE_SIGNATURE'
  | 'INITIALS'
  | 'DATE'
  | 'EMAIL'
  | 'NAME'
  | 'TEXT'
  | 'NUMBER'
  | 'CHECKBOX'
  | 'DROPDOWN'
  | 'RADIO';

/**
 * Typed metadata shapes per field type - surfaces what fieldMeta
 * actually carries in well-known cases. Used by the field-placement
 * UI to render the right config form per field type. Pass-through to
 * Documenso retains the loose `Record<string, unknown>` shape so we
 * can ship without locking down every property.
 */
export interface DocumensoTextFieldMeta {
  text?: string;
  label?: string;
  required?: boolean;
  readOnly?: boolean;
}
export interface DocumensoNumberFieldMeta {
  numberFormat?: string;
  min?: number;
  max?: number;
  required?: boolean;
}
export interface DocumensoChoiceOption {
  value: string;
  /** Whether the option is pre-selected. Applies to checkbox + radio. */
  checked?: boolean;
}
export interface DocumensoChoiceFieldMeta {
  values: DocumensoChoiceOption[];
  defaultValue?: string;
  validationRule?: string;
}

/**
 * Returns true when this field type expects a fieldMeta payload from
 * the placement UI (so the UI can prompt the rep to configure
 * options, defaults, validation, etc). Field types not in this list
 * carry no per-instance configuration beyond position + recipient.
 */
export function fieldTypeNeedsMeta(type: DocumensoFieldType): boolean {
  return (
    type === 'TEXT' ||
    type === 'NUMBER' ||
    type === 'CHECKBOX' ||
    type === 'DROPDOWN' ||
    type === 'RADIO'
  );
}

export interface DocumensoFieldPlacement {
  /** Documenso recipient id; v1 expects number, v2 string - coerced internally. */
  recipientId: number | string;
  type: DocumensoFieldType;
  pageNumber: number;
  /** All four are 0-100 percent of page dimensions. */
  pageX: number;
  pageY: number;
  pageWidth: number;
  pageHeight: number;
  /** Optional v2 fieldMeta - passed through verbatim, ignored on v1. */
  fieldMeta?: Record<string, unknown>;
}

export interface DocumensoPageDimensions {
  width: number;
  height: number;
}

const pageDimensionCache = new Map<string, DocumensoPageDimensions>();

/** Test seam - clears the page-dimension memoization. */
export function __resetDocumensoCachesForTests(): void {
  pageDimensionCache.clear();
}

/**
 * Place one or more fields on a Documenso document. Coordinates are PERCENT
 * (0-100) and converted to pixels for v1 internally.
 *
 * v1: dispatches one POST per field (no bulk endpoint).
 * v2: single bulk POST.
 */
export async function placeFields(
  docId: string,
  fields: DocumensoFieldPlacement[],
  portId?: string,
): Promise<void> {
  if (fields.length === 0) return;
  const { baseUrl, apiKey, apiVersion } = await resolveCreds(portId);

  if (apiVersion === 'v2') {
    // Documenso v2 field schema (confirmed against live trpc error
    // responses, 2026-05-22):
    //   - `recipientId` must be a NUMBER (the v1 client returns it
    //     stringified; we coerce here so callers can pass either form)
    //   - the page index field is named `page`, NOT `pageNumber` (v1's
    //     key) - wrong key surfaces as Zod "Required at data[i].page"
    //   - `positionX/Y` + `width/height` carry percent values (0-100)
    const v2Fields = fields.map((f) => ({
      recipientId: Number(f.recipientId),
      type: f.type,
      page: f.pageNumber,
      positionX: f.pageX,
      positionY: f.pageY,
      width: f.pageWidth,
      height: f.pageHeight,
      ...(f.fieldMeta ? { fieldMeta: f.fieldMeta } : {}),
    }));
    // Documenso v2 expects the field array under `data`, not `fields`
    // (the endpoint is a trpc-style createMany whose input schema wraps
    // the bulk payload in `{ envelopeId, data: [...] }`). The previous
    // shape returned 400: "Input validation failed - Required at data"
    // and surfaced as DOCUMENSO_UPSTREAM_ERROR on every custom-upload
    // send. Confirmed against the live Documenso 2.x error response.
    const res = await fetchWithTimeout(`${baseUrl}/api/v2/envelope/field/create-many`, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${apiKey}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ envelopeId: docId, data: v2Fields }),
    });
    if (!res.ok) {
      const err = await res.text();
      logger.error({ docId, status: res.status, err, portId }, 'Documenso v2 placeFields error');
      if (res.status === 401 || res.status === 403) {
        throw new CodedError('DOCUMENSO_AUTH_FAILURE', {
          internalMessage: `v2 placeFields ${docId} → ${res.status}`,
        });
      }
      throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
        internalMessage: `v2 placeFields ${docId} → ${res.status}: ${err}`,
      });
    }
    return;
  }

  for (const f of fields) {
    const body = {
      recipientId: typeof f.recipientId === 'string' ? Number(f.recipientId) : f.recipientId,
      type: f.type,
      pageNumber: f.pageNumber,
      // Documenso 2.x's v1-compat /fields endpoint expects PERCENT coords
      // (0-100), the same as v2 — NOT absolute points. (Confirmed live:
      // absolute values like 237 were read as 237% and placed fields far
      // off-page.) Send the percent values straight through.
      pageX: f.pageX,
      pageY: f.pageY,
      pageWidth: f.pageWidth,
      pageHeight: f.pageHeight,
      // Pass fieldMeta through on v1 too (Documenso 2.x's v1-compat endpoint
      // accepts it) so TEXT fields like "Place of Signing" keep their label /
      // required / placeholder. Older v1 servers ignore unknown keys.
      ...(f.fieldMeta ? { fieldMeta: f.fieldMeta } : {}),
    };
    // Retry transient failures so one flaky 5xx mid-loop doesn't leave
    // the document with a partial field set. 3 attempts at 250 / 500 /
    // 1000 ms; 4xx responses (validation errors) fail-fast.
    let lastError: { status: number; body: string } | null = null;
    for (let attempt = 0; attempt < 3; attempt += 1) {
      const res = await fetchWithTimeout(`${baseUrl}/api/v1/documents/${docId}/fields`, {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${apiKey}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(body),
      });
      if (res.ok) {
        lastError = null;
        break;
      }
      const errBody = await res.text().catch(() => '');
      lastError = { status: res.status, body: errBody };
      // Don't retry on 4xx - that's a validation error, won't change.
      if (res.status >= 400 && res.status < 500) break;
      // Backoff: 250ms, 500ms (skipped on the 3rd iteration because we exit).
      if (attempt < 2) {
        await new Promise((r) => setTimeout(r, 250 * Math.pow(2, attempt)));
      }
    }
    if (lastError) {
      logger.error(
        { docId, status: lastError.status, err: lastError.body, portId },
        'Documenso v1 placeField error',
      );
      if (lastError.status === 401 || lastError.status === 403) {
        throw new CodedError('DOCUMENSO_AUTH_FAILURE', {
          internalMessage: `v1 placeField ${docId} → ${lastError.status}`,
        });
      }
      throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
        internalMessage: `v1 placeField ${docId} → ${lastError.status}: ${lastError.body}`,
      });
    }
  }
}

/**
 * Auto-position one SIGNATURE field per recipient at the last-page footer,
 * staggered horizontally so multiple signers don't overlap. Used by the
 * upload-path wizard - admins can refine in Documenso afterwards.
 *
 * Layout (percent of page):
 *   y       = 88 (footer band)
 *   height  = 6
 *   width   = min(20, 80 / N)
 *   x       = i * (80/N) + (40 - 80/N * N / 2)  (centered row)
 */
export async function placeDefaultSignatureFields(
  docId: string,
  recipients: Array<{ id: number | string; pageNumber: number }>,
  portId?: string,
): Promise<void> {
  if (recipients.length === 0) return;
  const fields: DocumensoFieldPlacement[] = computeDefaultSignatureLayout(recipients);
  await placeFields(docId, fields, portId);
}

/** Pure function exported for unit testing layout math. */
export function computeDefaultSignatureLayout(
  recipients: Array<{ id: number | string; pageNumber: number }>,
): DocumensoFieldPlacement[] {
  const n = recipients.length;
  if (n === 0) return [];
  const slot = Math.min(20, 80 / n); // percent width per signer
  const rowWidth = slot * n;
  const startX = 50 - rowWidth / 2;
  return recipients.map((r, i) => ({
    recipientId: r.id,
    type: 'SIGNATURE',
    pageNumber: r.pageNumber,
    pageX: Math.max(0, startX + i * slot),
    pageY: 88,
    pageWidth: slot,
    pageHeight: 6,
  }));
}

/**
 * EOI page-3 signature-block layout — the six fields template 8 carries, so
 * the in-app pathway (local pdf-lib fill + flatten → upload as a Documenso
 * document) produces a signed EOI that matches the legacy template output
 * exactly. Coordinates are percent of page, captured verbatim from template 8.
 *
 * Client (signer 1) gets Signature + Name + Place-of-Signing (TEXT) + Date.
 * Developer (signer 2) gets Name + Signature. The approver (signer 3) carries
 * no fields. `fieldMeta` is passed through to Documenso (v1 + v2) so the
 * Place-of-Signing field keeps its label / required / placeholder.
 */
export function computeEoiSignatureLayout(
  clientRecipientId: number | string,
  developerRecipientId: number | string,
): DocumensoFieldPlacement[] {
  return [
    {
      recipientId: clientRecipientId,
      type: 'SIGNATURE',
      pageNumber: 3,
      pageX: 39.64497370960451,
      pageY: 64.81957098456644,
      pageWidth: 21.21662173851308,
      pageHeight: 4.303685358613111,
      fieldMeta: { type: 'signature', fontSize: 18, overflow: 'auto' },
    },
    {
      recipientId: clientRecipientId,
      type: 'NAME',
      pageNumber: 3,
      pageX: 14.34911393977768,
      pageY: 64.81957098456644,
      pageWidth: 24.33234194973456,
      pageHeight: 4.303685358613111,
      fieldMeta: { type: 'name', fontSize: 12, textAlign: 'left' },
    },
    {
      recipientId: clientRecipientId,
      type: 'TEXT',
      pageNumber: 3,
      pageX: 14.49704042881816,
      pageY: 57.4932908677896,
      pageWidth: 24.4807121661721,
      pageHeight: 4.40865329418904,
      fieldMeta: {
        type: 'text',
        label: 'Place of Signing',
        readOnly: false,
        required: true,
        textAlign: 'left',
        placeholder: 'Anguilla, AI',
        characterLimit: 0,
      },
    },
    {
      recipientId: clientRecipientId,
      type: 'DATE',
      pageNumber: 3,
      pageX: 39.79290246256028,
      pageY: 57.4932908677896,
      pageWidth: 21.06824925816024,
      pageHeight: 4.40865329418904,
      fieldMeta: { type: 'date', fontSize: 10, overflow: 'auto', textAlign: 'left' },
    },
    {
      recipientId: developerRecipientId,
      type: 'NAME',
      pageNumber: 3,
      pageX: 14.34911393977768,
      pageY: 72.56877244919716,
      pageWidth: 24.33234194973456,
      pageHeight: 3.988781551885322,
      fieldMeta: { type: 'name', fontSize: 12, textAlign: 'left' },
    },
    {
      recipientId: developerRecipientId,
      type: 'SIGNATURE',
      pageNumber: 3,
      pageX: 39.64497370960451,
      pageY: 72.56877244919716,
      pageWidth: 21.21662173851308,
      pageHeight: 3.988781551885322,
      fieldMeta: { type: 'signature', fontSize: 18, overflow: 'auto' },
    },
  ];
}

/**
 * Void/cancel a Documenso document.
 *
 * v1: DELETE /api/v1/documents/{id}
 * v2: DELETE /api/v2/envelope/{id}
 *
 * Idempotent on 404 (already gone) - logs and resolves.
 */
export async function voidDocument(docId: string, portId?: string): Promise<void> {
  const { baseUrl, apiKey, apiVersion } = await resolveCreds(portId);
  const path = apiVersion === 'v2' ? `/api/v2/envelope/${docId}` : `/api/v1/documents/${docId}`;
  const res = await fetchWithTimeout(`${baseUrl}${path}`, {
    method: 'DELETE',
    headers: { Authorization: `Bearer ${apiKey}` },
  });
  if (res.status === 404) {
    logger.warn({ docId, portId }, 'Documenso voidDocument: already deleted');
    return;
  }
  if (!res.ok) {
    const err = await res.text();
    logger.error({ docId, status: res.status, err, portId }, 'Documenso voidDocument error');
    if (res.status === 401 || res.status === 403) {
      throw new CodedError('DOCUMENSO_AUTH_FAILURE', {
        internalMessage: `voidDocument ${docId} → ${res.status}`,
      });
    }
    throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
      internalMessage: `voidDocument ${docId} → ${res.status}: ${err}`,
    });
  }
}

/**
 * Update an envelope's metadata while it's still in DRAFT or PENDING - title,
 * subject, message, redirect URL, signing order, language. v2-only feature
 * (Documenso 1.13.x has no equivalent; admins on v1 need to void + recreate).
 *
 * Returns the normalized document shape so callers can persist the latest
 * title locally in the `documents` row.
 */
export async function updateEnvelope(
  docId: string,
  patch: {
    title?: string;
    meta?: {
      subject?: string;
      message?: string;
      redirectUrl?: string;
      language?: string;
      signingOrder?: 'PARALLEL' | 'SEQUENTIAL';
      timezone?: string;
      dateFormat?: string;
    };
  },
  portId?: string,
): Promise<DocumensoDocument> {
  const { apiVersion } = await resolveCreds(portId);
  if (apiVersion !== 'v2') {
    throw new CodedError('DOCUMENSO_V1_NOT_SUPPORTED', {
      internalMessage:
        'updateEnvelope requires Documenso 2.x - the v1.13.x API has no envelope/update endpoint',
    });
  }
  // v2 update is POST /api/v2/envelope/update with the envelopeId in the
  // body - NOT a PATCH against a per-id path. The body splits document
  // properties (title, externalId, visibility, email) under `data` from
  // email/signing settings under `meta`. Restricted to DRAFT envelopes.
  const body: Record<string, unknown> = { envelopeId: docId };
  if (patch.title !== undefined) {
    body.data = { title: patch.title };
  }
  if (patch.meta) {
    body.meta = patch.meta;
  }
  return documensoFetch(
    `/api/v2/envelope/update`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    },
    portId,
  ).then(normalizeDocument);
}
