'use client';

import { useEffect, useMemo, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Document, Page, pdfjs } from 'react-pdf';
import { toast } from 'sonner';
import {
  ChevronLeft,
  ChevronRight,
  Loader2,
  Plus,
  Trash2,
  X,
  Pen,
  Calendar as CalendarIcon,
  Type,
  CheckSquare,
  Mail,
  User as UserIcon,
} from 'lucide-react';

import { Button } from '@/components/ui/button';
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';

import 'react-pdf/dist/Page/AnnotationLayer.css';
import 'react-pdf/dist/Page/TextLayer.css';

pdfjs.GlobalWorkerOptions.workerSrc = `https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;

/**
 * Phase 4 - Upload-for-Documenso-signing dialog.
 *
 * Four-step flow inside one dialog:
 *   1. select-file        - drag/drop or click to upload a PDF
 *   2. configure-recipients - name/email/role per signer, with
 *      client + developer + approver prefilled from port + interest
 *   3. place-fields       - render the PDF page-by-page, run
 *      auto-detect, let the rep drag/place/delete fields per signer
 *   4. sending            - POST to /upload-for-signing, show spinner
 *
 * The implementation is intentionally compact - the field-overlay
 * uses native DOM drag rather than dnd-kit so the coordinate math
 * stays obvious. Auto-detect lives on the server (uses pdfjs-dist) so
 * the same parser ships once.
 */

interface Recipient {
  name: string;
  email: string;
  role: 'SIGNER' | 'APPROVER' | 'CC';
  signingOrder: number;
}

type FieldType =
  | 'SIGNATURE'
  | 'FREE_SIGNATURE'
  | 'INITIALS'
  | 'DATE'
  | 'EMAIL'
  | 'NAME'
  | 'TEXT'
  | 'NUMBER'
  | 'CHECKBOX'
  | 'DROPDOWN'
  | 'RADIO';

interface PlacedField {
  /** Client-side id only - server doesn't see this. */
  id: string;
  type: FieldType;
  recipientIndex: number;
  pageNumber: number;
  /** All 0..100 percent of page dimensions. */
  pageX: number;
  pageY: number;
  pageWidth: number;
  pageHeight: number;
  /** Per-instance default value the signer sees on render. For TEXT/NUMBER
   *  this is the prefilled string; for DROPDOWN it's the option `value`
   *  string; for CHECKBOX/RADIO the meta.values entries carry their own
   *  `checked` flags so this is left null. */
  defaultValue?: string | null;
  /** Documenso v2 fieldMeta passed through verbatim. Per-type shape:
   *    TEXT/SIGNATURE/INITIALS: { text?, label?, required?, readOnly? }
   *    NUMBER:                  { numberFormat?, min?, max?, required? }
   *    DATE:                    { dateFormat?, required? }
   *    CHECKBOX/RADIO:          { values: [{ value, checked? }] }
   *    DROPDOWN:                { values: [{ value }], defaultValue? }
   *  Ignored on v1 instances. */
  fieldMeta?: Record<string, unknown>;
}

interface DetectedFieldResponse {
  type: FieldType;
  pageNumber: number;
  pageX: number;
  pageY: number;
  pageWidth: number;
  pageHeight: number;
  confidence: number;
  anchorText?: string;
  inferredRecipientLabel?: string | null;
}

interface SigningDefaults {
  developer: { name: string; email: string; label: string };
  approver: { name: string; email: string; label: string };
  sendMode: 'auto' | 'manual';
}

const FIELD_DEFAULTS: Record<
  FieldType,
  { widthPct: number; heightPct: number; label: string; icon: typeof Pen }
> = {
  SIGNATURE: { widthPct: 20, heightPct: 5, label: 'Signature', icon: Pen },
  FREE_SIGNATURE: { widthPct: 20, heightPct: 5, label: 'Free signature', icon: Pen },
  INITIALS: { widthPct: 8, heightPct: 5, label: 'Initials', icon: Pen },
  DATE: { widthPct: 12, heightPct: 3, label: 'Date', icon: CalendarIcon },
  EMAIL: { widthPct: 25, heightPct: 3, label: 'Email', icon: Mail },
  NAME: { widthPct: 20, heightPct: 3, label: 'Name', icon: UserIcon },
  TEXT: { widthPct: 25, heightPct: 3, label: 'Text', icon: Type },
  NUMBER: { widthPct: 15, heightPct: 3, label: 'Number', icon: Type },
  CHECKBOX: { widthPct: 3, heightPct: 3, label: 'Checkbox', icon: CheckSquare },
  DROPDOWN: { widthPct: 20, heightPct: 3, label: 'Dropdown', icon: Type },
  RADIO: { widthPct: 3, heightPct: 3, label: 'Radio', icon: CheckSquare },
};

const RECIPIENT_COLORS = [
  'rgb(59 130 246)', // blue-500
  'rgb(168 85 247)', // purple-500
  'rgb(34 197 94)', // green-500
  'rgb(249 115 22)', // orange-500
  'rgb(239 68 68)', // red-500
  'rgb(20 184 166)', // teal-500
];

/**
 * Display label + colour tint for the three Documenso recipient roles.
 * Surfaced as a small pill next to each recipient in the placement
 * step's sidebar AND in the FieldSidePanel's assignment dropdown so
 * the rep sees the role context without hunting for it in step 2.
 *
 *   - SIGNER: active responsibility (must sign + fill fields). Blue.
 *   - APPROVER: gate before signers (approves before the envelope
 *     reaches the next recipient). Amber to read as oversight, not
 *     blocker.
 *   - CC: receives the final document for awareness; no action. Muted.
 */
const RECIPIENT_ROLE_META: Record<Recipient['role'], { label: string; className: string }> = {
  SIGNER: {
    label: 'Signer',
    className: 'bg-blue-100 text-blue-900 border-blue-200',
  },
  APPROVER: {
    label: 'Approver',
    className: 'bg-amber-100 text-amber-900 border-amber-200',
  },
  CC: {
    label: 'CC',
    className: 'bg-slate-100 text-slate-700 border-slate-200',
  },
};

function RecipientRoleBadge({ role }: { role: Recipient['role'] }) {
  const meta = RECIPIENT_ROLE_META[role];
  return (
    <span
      className={`inline-flex items-center rounded-full border px-1.5 py-0 text-xs font-medium ${meta.className}`}
      title={`Role: ${meta.label}`}
    >
      {meta.label}
    </span>
  );
}

export interface UploadForSigningEntity {
  type: 'client' | 'company' | 'yacht';
  id: string;
  /** Display label only — used in the dialog header so the rep can
   *  see which entity the doc will be filed under. */
  label?: string;
}

interface UploadForSigningDialogProps {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  /** Required for eoi / contract / reservation_agreement (the pipeline
   *  side effects need it). MUST be null for documentType='generic' —
   *  in that case the upload routes through the generic endpoint and
   *  optionally files the doc against the supplied `entity`. */
  interestId: string | null;
  documentType: 'eoi' | 'contract' | 'reservation_agreement' | 'generic';
  /** Optional: client name/email to prefill the first recipient.
   *  When omitted the dialog fetches from the interest (interest-scoped
   *  flows) or leaves the recipient blank (generic flow). */
  clientPrefill?: { name: string; email: string };
  /** Generic flow only: routes the resulting file/document row to the
   *  entity's FK column + auto-files it into the entity's system
   *  folder. Ignored when `interestId` is set. */
  entity?: UploadForSigningEntity;
  /** Generic flow only: explicit folder placement (e.g. rep is
   *  uploading from within a Documents Hub folder). */
  folderId?: string | null;
  /** Generic flow only: caller-supplied success hook. Receives the
   *  new documentId and can invalidate caches / show a toast. */
  onCreated?: (result: { documentId: string }) => void;
}

export function UploadForSigningDialog({
  open,
  onOpenChange,
  interestId,
  documentType,
  clientPrefill,
  entity,
  folderId,
  onCreated,
}: UploadForSigningDialogProps) {
  // Re-mount the body on every open so all state resets cleanly. Same
  // pattern as hard-delete-dialog (set-state-in-effect avoidance).
  if (!open) return null;
  const draftKey = interestId ?? entity?.id ?? 'generic';
  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent className="sm:max-w-[1400px] w-[95vw] max-h-[90vh] overflow-hidden p-0 flex flex-col">
        <DialogBody
          key={`${draftKey}:${documentType}`}
          interestId={interestId}
          documentType={documentType}
          clientPrefill={clientPrefill}
          entity={entity}
          folderId={folderId ?? null}
          onCreated={onCreated}
          onClose={() => onOpenChange(false)}
        />
      </DialogContent>
    </Dialog>
  );
}

type Step = 'select-file' | 'configure-recipients' | 'place-fields';

/**
 * localStorage key for draft persistence. Versioned (`v1`) so a future
 * shape change can invalidate stale drafts without crashing the parser.
 * Scoped per interest+documentType so a rep can have an in-flight
 * contract upload AND reservation upload in the same browser session
 * without them clobbering each other.
 */
function draftStorageKey(scopeId: string, documentType: string): string {
  return `pn-crm.upload-for-signing.draft.v1:${scopeId}:${documentType}`;
}

interface PersistedDraft {
  step: Step;
  title: string;
  recipients: Recipient[];
  fields: PlacedField[];
  invitationMessage: string;
  /** Saved at timestamp - surfaces in the UI as "Draft saved <relative>". */
  savedAt: string;
}

function loadDraft(scopeId: string, documentType: string): PersistedDraft | null {
  if (typeof window === 'undefined') return null;
  try {
    const raw = window.localStorage.getItem(draftStorageKey(scopeId, documentType));
    if (!raw) return null;
    const parsed = JSON.parse(raw) as PersistedDraft;
    // Defensive shape check - drop drafts that look malformed rather
    // than crashing the dialog.
    if (
      typeof parsed.title !== 'string' ||
      !Array.isArray(parsed.recipients) ||
      !Array.isArray(parsed.fields)
    ) {
      return null;
    }
    return parsed;
  } catch {
    return null;
  }
}

function saveDraft(scopeId: string, documentType: string, draft: PersistedDraft): void {
  if (typeof window === 'undefined') return;
  try {
    window.localStorage.setItem(draftStorageKey(scopeId, documentType), JSON.stringify(draft));
  } catch {
    // localStorage may throw on private mode or quota - swallow.
  }
}

function clearDraft(scopeId: string, documentType: string): void {
  if (typeof window === 'undefined') return;
  try {
    window.localStorage.removeItem(draftStorageKey(scopeId, documentType));
  } catch {
    // ignore
  }
}

function DialogBody({
  interestId,
  documentType,
  clientPrefill,
  entity,
  folderId,
  onCreated,
  onClose,
}: {
  interestId: string | null;
  documentType: 'eoi' | 'contract' | 'reservation_agreement' | 'generic';
  clientPrefill?: { name: string; email: string };
  entity?: UploadForSigningEntity;
  folderId?: string | null;
  onCreated?: (result: { documentId: string }) => void;
  onClose: () => void;
}) {
  // Draft scope: interestId when scoped to a deal, otherwise the
  // entity id (so the rep can have one in-flight upload per entity),
  // else 'generic' for the root Documents Hub flow.
  const draftScopeId = interestId ?? entity?.id ?? 'generic';

  // Hydrate from the persisted draft once on mount. The `key` prop on
  // the parent re-mounts this body on every open, so this useState
  // initializer runs once per dialog session.
  const initialDraft = useMemo(
    () => loadDraft(draftScopeId, documentType),
    [draftScopeId, documentType],
  );

  const [step, setStep] = useState<Step>(initialDraft?.step ?? 'select-file');
  const [file, setFile] = useState<File | null>(null);
  const [title, setTitle] = useState(initialDraft?.title ?? '');
  const [recipients, setRecipients] = useState<Recipient[]>(initialDraft?.recipients ?? []);
  const [fields, setFields] = useState<PlacedField[]>(initialDraft?.fields ?? []);
  const [selectedFieldId, setSelectedFieldId] = useState<string | null>(null);
  // Phase 6 polish - optional rep-authored note that appears above the
  // CTA in every invitation email for this doc. Empty string means
  // "no custom note - use the template default copy".
  const [invitationMessage, setInvitationMessage] = useState(initialDraft?.invitationMessage ?? '');
  const [draftSavedAt, setDraftSavedAt] = useState<string | null>(initialDraft?.savedAt ?? null);

  const docLabel =
    documentType === 'contract'
      ? 'Sales Contract'
      : documentType === 'eoi'
        ? 'Expression of Interest'
        : documentType === 'reservation_agreement'
          ? 'Reservation Agreement'
          : 'Document';

  // Defaults endpoint - drives the developer/approver prefill.
  const { data: defaults } = useQuery<{ data: SigningDefaults }>({
    queryKey: ['documents', 'signing-defaults'],
    queryFn: () => apiFetch<{ data: SigningDefaults }>('/api/v1/documents/signing-defaults'),
  });

  // Interest endpoint - used to prefill the client recipient when the
  // caller didn't supply one. Cached so the same dialog open/reopen
  // hits the cache. Skipped entirely on the generic path (no interest).
  const { data: interestData } = useQuery<{
    data: { client: { fullName: string; email: string | null } };
  }>({
    queryKey: ['interest', interestId, 'prefill'],
    queryFn: () =>
      apiFetch<{ data: { client: { fullName: string; email: string | null } } }>(
        `/api/v1/interests/${interestId}`,
      ),
    enabled: Boolean(interestId) && !clientPrefill,
  });

  // P4.2 - Inheritance-driven public-map flag. Drives an opt-in
  // checkbox on the file-select step that flips in-bundle berths to
  // is_specific_interest=true on submit, default ON when any in-bundle
  // berth on this interest is already on the public map. Only fetches
  // when the dialog is interest-scoped (generic uploads have no berths).
  const { data: interestBerthsRes } = useQuery<{
    data: Array<{
      berthId: string;
      mooringNumber: string | null;
      isInEoiBundle: boolean;
      isSpecificInterest: boolean;
    }>;
  }>({
    queryKey: ['interests', interestId, 'berths'],
    queryFn: () =>
      apiFetch<{
        data: Array<{
          berthId: string;
          mooringNumber: string | null;
          isInEoiBundle: boolean;
          isSpecificInterest: boolean;
        }>;
      }>(`/api/v1/interests/${interestId}/berths`),
    enabled: Boolean(interestId),
    staleTime: 60_000,
  });
  const inBundleBerths = useMemo(
    () => (interestBerthsRes?.data ?? []).filter((b) => b.isInEoiBundle),
    [interestBerthsRes],
  );
  const inheritsPublicFlag = useMemo(
    () => inBundleBerths.some((b) => b.isSpecificInterest),
    [inBundleBerths],
  );
  const [publicFlagOverride, setPublicFlagOverride] = useState<boolean | null>(null);
  const publicFlagChecked = publicFlagOverride ?? inheritsPublicFlag;

  /**
   * Build the prefill recipient list from the async query data. The
   * dialog reads this on the "Next" button click in the file-picker
   * step to seed `recipients` - keeping the seeding as a user-event
   * handler rather than an effect avoids the cascading-render lint
   * (react-hooks/set-state-in-effect, Wave 3) that earlier versions
   * tripped. Returns an empty array until the defaults query resolves;
   * the file-picker step's Next button stays clickable so the rep can
   * still proceed and add recipients manually if the defaults endpoint
   * is slow.
   */
  const prefillRecipients = useMemo<Recipient[]>(() => {
    if (!defaults?.data) return [];
    const client = clientPrefill ?? {
      name: interestData?.data?.client?.fullName ?? '',
      email: interestData?.data?.client?.email ?? '',
    };
    const next: Recipient[] = [];
    if (client.name && client.email) {
      next.push({ name: client.name, email: client.email, role: 'SIGNER', signingOrder: 1 });
    }
    if (defaults.data.developer.email) {
      next.push({
        name: defaults.data.developer.name || defaults.data.developer.label,
        email: defaults.data.developer.email,
        role: 'SIGNER',
        signingOrder: next.length + 1,
      });
    }
    if (defaults.data.approver.email) {
      next.push({
        name: defaults.data.approver.name || defaults.data.approver.label,
        email: defaults.data.approver.email,
        role: 'APPROVER',
        signingOrder: next.length + 1,
      });
    }
    return next;
  }, [defaults, interestData, clientPrefill]);

  // We previously passed an object URL into react-pdf, but PDF.js runs
  // its parser in a Web Worker loaded from unpkg.com (a different
  // origin from localhost). Cross-origin workers can't fetch blob URLs
  // minted on the main page - the worker XHR returns response (0) and
  // the preview surfaces "Unexpected server response (0)". Reading the
  // file into an ArrayBuffer once and handing PDF.js the raw bytes via
  // `{ data: ... }` sidesteps the fetch entirely, so the cross-origin
  // worker has nothing to retrieve.
  const [fileBytes, setFileBytes] = useState<Uint8Array | null>(null);
  useEffect(() => {
    if (!file) {
      // eslint-disable-next-line react-hooks/set-state-in-effect -- clear preview bytes when caller drops the file
      setFileBytes(null);
      return;
    }
    let cancelled = false;
    void file.arrayBuffer().then((buf) => {
      if (!cancelled) setFileBytes(new Uint8Array(buf));
    });
    return () => {
      cancelled = true;
    };
  }, [file]);

  // Persist the rep's progress to localStorage as they work. Debounced
  // at 500ms so a flurry of state updates (typing a long invitation
  // message, dragging a field across the page) doesn't hammer storage.
  // We DO NOT persist the File object itself - the rep has to re-pick
  // the PDF after a refresh. Everything else (title, signers,
  // placements, custom note) round-trips. The `step` is restored too
  // so the dialog reopens on the same screen the rep left.
  const draftDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  useEffect(() => {
    if (draftDebounceRef.current) clearTimeout(draftDebounceRef.current);
    draftDebounceRef.current = setTimeout(() => {
      // Skip persistence in the pristine "no progress yet" state so
      // dismissing the dialog without touching anything doesn't leave
      // a phantom draft behind.
      const hasProgress =
        title.length > 0 ||
        recipients.length > 0 ||
        fields.length > 0 ||
        invitationMessage.length > 0;
      if (!hasProgress) {
        clearDraft(draftScopeId, documentType);
        return;
      }
      const now = new Date().toISOString();
      saveDraft(draftScopeId, documentType, {
        step,
        title,
        recipients,
        fields,
        invitationMessage,
        savedAt: now,
      });

      setDraftSavedAt(now);
    }, 500);
    return () => {
      if (draftDebounceRef.current) clearTimeout(draftDebounceRef.current);
    };
  }, [step, title, recipients, fields, invitationMessage, draftScopeId, documentType]);

  function discardDraft() {
    clearDraft(draftScopeId, documentType);
    setTitle('');
    setRecipients([]);
    setFields([]);
    setInvitationMessage('');
    setStep('select-file');
    setDraftSavedAt(null);
  }

  const autoDetect = useMutation({
    mutationFn: async (uploadedFile: File) => {
      const form = new FormData();
      form.append('file', uploadedFile);
      // apiFetch JSON-encodes the body when set, so we go raw here.
      const res = await fetch('/api/v1/documents/auto-detect-fields', {
        method: 'POST',
        body: form,
        credentials: 'include',
      });
      if (!res.ok) throw new Error('Auto-detect failed');
      return (await res.json()) as { data: { fields: DetectedFieldResponse[] } };
    },
    onSuccess: (res) => {
      // Drop detected fields onto a temporary "unassigned" recipient
      // (index 0). Rep reassigns via the side panel.
      const placed: PlacedField[] = res.data.fields.map((f) => ({
        id: `det-${Math.random().toString(36).slice(2, 10)}`,
        type: f.type,
        recipientIndex: 0,
        pageNumber: f.pageNumber,
        pageX: f.pageX,
        pageY: f.pageY,
        pageWidth: f.pageWidth,
        pageHeight: f.pageHeight,
      }));
      setFields((existing) => [...existing, ...placed]);
      if (placed.length > 0) {
        toast.success(
          `Auto-detect placed ${placed.length} field${placed.length === 1 ? '' : 's'}.`,
        );
      } else {
        toast.info('No fields auto-detected - place them manually.');
      }
    },
    onError: () => {
      toast.info('Auto-detect skipped - place fields manually.');
    },
  });

  // Pre-flight validation — surfaces every reason Submit would fail
  // BEFORE the rep clicks, so they don't hit a partial-state Documenso
  // failure. Mirrors the service-side validation in
  // custom-document-upload.service.ts so the UI rejection set always
  // matches what the server would reject. Hard block: Submit stays
  // disabled until this returns []. Per the 2026-05-26 decision, no
  // override path — Documenso's API can't recover from missing emails.
  const submissionErrors = useMemo<string[]>(() => {
    const errors: string[] = [];
    if (recipients.length === 0) {
      errors.push('Add at least one recipient before sending.');
    }
    const emailRegex = /^\S+@\S+\.\S+$/;
    recipients.forEach((r) => {
      const label = `Recipient #${r.signingOrder}`;
      const name = (r.name ?? '').trim();
      const email = (r.email ?? '').trim();
      if (!name) {
        errors.push(`${label} is missing a name.`);
      }
      if (!email) {
        errors.push(`${label} is missing an email address.`);
      } else if (!emailRegex.test(email)) {
        errors.push(`${label} has an invalid email address (${email}).`);
      }
    });
    if (fields.length === 0) {
      errors.push('Place at least one field on the document.');
    }
    fields.forEach((f, idx) => {
      const r = recipients[f.recipientIndex];
      if (!r) {
        errors.push(
          `Field #${idx + 1} (${FIELD_DEFAULTS[f.type].label} on page ${f.pageNumber}) is assigned to a recipient that no longer exists. Re-assign or delete it.`,
        );
      }
    });
    return errors;
  }, [recipients, fields]);

  const queryClient = useQueryClient();
  const sendMutation = useMutation({
    mutationFn: async () => {
      if (!file) throw new Error('No file selected');
      const form = new FormData();
      form.append('file', file);
      form.append('documentType', documentType);
      form.append('title', title || file.name.replace(/\.pdf$/i, ''));
      form.append('recipients', JSON.stringify(recipients));
      if (invitationMessage.trim()) {
        form.append('invitationMessage', invitationMessage.trim());
      }
      // Strip the client-side `id` from each placed field - the server
      // assigns its own ids on the documenso side.
      form.append(
        'fields',
        JSON.stringify(
          fields.map((f) => ({
            type: f.type,
            recipientIndex: f.recipientIndex,
            pageNumber: f.pageNumber,
            pageX: f.pageX,
            pageY: f.pageY,
            pageWidth: f.pageWidth,
            pageHeight: f.pageHeight,
            ...(f.fieldMeta && Object.keys(f.fieldMeta).length > 0
              ? { fieldMeta: f.fieldMeta }
              : {}),
          })),
        ),
      );
      // Generic envelopes go to the cross-cutting endpoint; the
      // entity / folder context piggybacks on the form so the file
      // row lands under the right system folder. Interest-scoped
      // flows keep their dedicated route so the pipeline-stage
      // advance + doc-status flip side effects fire.
      if (interestId) {
        if (documentType === 'generic') {
          throw new Error('Generic documentType requires interestId=null');
        }
      } else {
        if (entity) form.append('entity', JSON.stringify({ type: entity.type, id: entity.id }));
        if (folderId) form.append('folderId', folderId);
      }
      const endpoint = interestId
        ? `/api/v1/interests/${interestId}/upload-for-signing`
        : `/api/v1/upload-for-signing`;
      const res = await fetch(endpoint, {
        method: 'POST',
        body: form,
        credentials: 'include',
      });
      if (!res.ok) {
        const body = (await res.json().catch(() => ({}))) as {
          error?: string;
          requestId?: string;
        };
        const err = new Error(body.error ?? 'Upload failed');
        // Tack the requestId onto the Error so toastError can surface it.
        (err as Error & { requestId?: string }).requestId = body.requestId;
        throw err;
      }
      return res.json() as Promise<{
        data: { documentId: string; signingUrls: Record<string, string> };
      }>;
    },
    onSuccess: async (res) => {
      // Sync the public-map flag across the in-bundle berths. Skips
      // already-aligned rows. Failures are non-fatal — the doc upload
      // already succeeded, so surface a non-blocking toast.
      if (interestId && inBundleBerths.length > 0) {
        const targets = inBundleBerths.filter((b) => b.isSpecificInterest !== publicFlagChecked);
        if (targets.length > 0) {
          try {
            await Promise.all(
              targets.map((b) =>
                apiFetch(`/api/v1/interests/${interestId}/berths/${b.berthId}`, {
                  method: 'PATCH',
                  body: { isSpecificInterest: publicFlagChecked },
                }),
              ),
            );
          } catch {
            toast.error('Upload succeeded, but the public-map flag could not be updated.');
          }
        }
      }
      toast.success(
        defaults?.data?.sendMode === 'auto'
          ? 'Document sent for signing - first signer has been invited.'
          : 'Document uploaded and ready to send. Use the Send button on the doc to email the first signer.',
      );
      queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'documents' });
      queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'interest' });
      queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'files' });
      queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'berth' });
      if (onCreated && res?.data?.documentId) {
        onCreated({ documentId: res.data.documentId });
      }
      clearDraft(draftScopeId, documentType);
      setPublicFlagOverride(null);
      onClose();
    },
    onError: (err) => toastError(err, 'Upload failed'),
  });

  // ─── Step renderers ─────────────────────────────────────────────
  return (
    <>
      <DialogHeader className="px-6 pt-6 pb-2 flex-shrink-0">
        <div className="flex items-start justify-between gap-3">
          <div className="min-w-0 flex-1">
            <DialogTitle>Send {docLabel.toLowerCase()} for signing</DialogTitle>
            <DialogDescription>
              {step === 'select-file' && 'Upload the draft PDF to send via Documenso.'}
              {step === 'configure-recipients' && 'Confirm who needs to sign and in what order.'}
              {step === 'place-fields' &&
                'Place signing fields where each recipient needs to sign, date, or fill in. Click a palette button then click on the PDF to place a field.'}
            </DialogDescription>
          </div>
          {/* Draft-saved indicator + Discard button. Renders when there's
              a persisted draft so the rep knows progress is saved across
              dialog open / close cycles. Discard wipes the draft and
              resets to the file-picker step. The file itself isn't
              persisted (large blobs + browser quota), so on reopen the
              rep needs to re-pick the PDF - the rest of the state
              (title, signers, placements, custom note) survives. */}
          {draftSavedAt ? (
            <div className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
              <span title={`Draft auto-saved ${new Date(draftSavedAt).toLocaleString()}`}>
                Draft saved
              </span>
              <Button
                type="button"
                variant="ghost"
                size="sm"
                className="h-6 px-2 text-xs text-muted-foreground hover:text-destructive"
                onClick={discardDraft}
              >
                Discard
              </Button>
            </div>
          ) : null}
        </div>
      </DialogHeader>

      <div className="flex-1 overflow-hidden flex flex-col">
        {step === 'select-file' && (
          <>
            <FilePickerStep
              onFileSelected={(f) => {
                setFile(f);
                setTitle(f.name.replace(/\.pdf$/i, ''));
                if (recipients.length === 0 && prefillRecipients.length > 0) {
                  setRecipients(prefillRecipients);
                }
                setStep('configure-recipients');
                autoDetect.mutate(f);
              }}
              title={title}
              onTitleChange={setTitle}
            />
            {interestId && inBundleBerths.length > 0 ? (
              <div className="mx-6 my-2 rounded-md border bg-muted/40 p-3 text-sm">
                <label className="flex cursor-pointer items-start gap-2">
                  <input
                    type="checkbox"
                    checked={publicFlagChecked}
                    onChange={(e) => setPublicFlagOverride(e.target.checked)}
                    className="mt-1 h-4 w-4 cursor-pointer"
                  />
                  <span>
                    <span className="font-medium">
                      Mark berth{inBundleBerths.length > 1 ? 's' : ''} as Under Offer on the public
                      map
                    </span>
                    <span className="block text-xs text-muted-foreground">
                      {inheritsPublicFlag
                        ? 'Default ON because at least one in-bundle berth is already flagged on the public map.'
                        : 'Default OFF - turn on to make the berth(s) visible as Under Offer on the public site.'}
                    </span>
                  </span>
                </label>
              </div>
            ) : null}
          </>
        )}
        {step === 'configure-recipients' && (
          <RecipientsStep
            recipients={recipients}
            onChange={setRecipients}
            title={title}
            onTitleChange={setTitle}
            invitationMessage={invitationMessage}
            onInvitationMessageChange={setInvitationMessage}
          />
        )}
        {step === 'place-fields' && fileBytes && (
          <FieldPlacementStep
            fileBytes={fileBytes}
            fields={fields}
            onFieldsChange={setFields}
            recipients={recipients}
            selectedFieldId={selectedFieldId}
            onSelectField={setSelectedFieldId}
            isDetecting={autoDetect.isPending}
          />
        )}
      </div>

      {/* Pre-flight validation summary. Surfaces every reason Submit
          would fail BEFORE the rep clicks, so a missing email or a
          field assigned to a deleted recipient is caught here instead
          of via a Documenso silent-failure round-trip. Renders only on
          the place-fields step (where Submit lives) and only when
          there are issues to call out. */}
      {step === 'place-fields' && submissionErrors.length > 0 && (
        <div
          role="alert"
          aria-live="polite"
          className="mx-6 mb-2 flex-shrink-0 rounded-md border border-amber-300 bg-amber-50 p-3 text-xs"
        >
          <p className="font-semibold text-amber-900">
            Resolve {submissionErrors.length} issue
            {submissionErrors.length === 1 ? '' : 's'} before sending:
          </p>
          <ul className="mt-1 list-inside list-disc space-y-0.5 text-amber-900">
            {submissionErrors.map((e, i) => (
              <li key={i}>{e}</li>
            ))}
          </ul>
        </div>
      )}

      <DialogFooter className="px-6 py-4 border-t flex-shrink-0 flex items-center gap-2">
        <StepIndicator step={step} />
        <div className="ml-auto flex gap-2">
          {step === 'configure-recipients' && (
            <Button variant="outline" onClick={() => setStep('select-file')}>
              Back
            </Button>
          )}
          {step === 'place-fields' && (
            <Button variant="outline" onClick={() => setStep('configure-recipients')}>
              Back
            </Button>
          )}
          {step !== 'place-fields' && (
            <Button
              onClick={() => {
                if (step === 'select-file') {
                  if (!file) {
                    toast.error('Pick a PDF first');
                    return;
                  }
                  if (recipients.length === 0 && prefillRecipients.length > 0) {
                    setRecipients(prefillRecipients);
                  }
                  setStep('configure-recipients');
                } else if (step === 'configure-recipients') {
                  if (recipients.length === 0) {
                    toast.error('Add at least one recipient');
                    return;
                  }
                  if (recipients.some((r) => !r.email || !r.name)) {
                    toast.error('Every recipient needs a name and email');
                    return;
                  }
                  setStep('place-fields');
                }
              }}
            >
              Next
            </Button>
          )}
          {step === 'place-fields' && (
            <Button
              onClick={() => sendMutation.mutate()}
              disabled={sendMutation.isPending || submissionErrors.length > 0}
              title={
                submissionErrors.length > 0
                  ? `Resolve ${submissionErrors.length} issue${submissionErrors.length === 1 ? '' : 's'} before sending`
                  : undefined
              }
            >
              {sendMutation.isPending && <Loader2 className="size-4 mr-2 animate-spin" />}
              {defaults?.data?.sendMode === 'auto' ? 'Send for signing' : 'Send'}
            </Button>
          )}
        </div>
      </DialogFooter>
    </>
  );
}

function StepIndicator({ step }: { step: Step }) {
  const dots = [
    { key: 'select-file', label: 'File' },
    { key: 'configure-recipients', label: 'Recipients' },
    { key: 'place-fields', label: 'Fields' },
  ] as const;
  const activeIdx = dots.findIndex((d) => d.key === step);
  return (
    <div className="flex items-center gap-2 text-xs text-muted-foreground">
      {dots.map((d, i) => (
        <span key={d.key} className="flex items-center gap-1.5">
          <span
            className={
              'size-2 rounded-full ' + (i <= activeIdx ? 'bg-foreground' : 'bg-muted-foreground/30')
            }
            aria-hidden
          />
          <span className={i === activeIdx ? 'font-medium text-foreground' : ''}>{d.label}</span>
          {i < dots.length - 1 && <span className="text-muted-foreground/40">›</span>}
        </span>
      ))}
    </div>
  );
}

// ─── Step 1: file picker ──────────────────────────────────────────

function FilePickerStep({
  onFileSelected,
  title,
  onTitleChange,
}: {
  onFileSelected: (file: File) => void;
  title: string;
  onTitleChange: (t: string) => void;
}) {
  const [dragging, setDragging] = useState(false);
  const inputRef = useRef<HTMLInputElement>(null);

  function handleFile(f: File) {
    if (!f.name.toLowerCase().endsWith('.pdf') && f.type !== 'application/pdf') {
      toast.error('Only PDF files are accepted');
      return;
    }
    onFileSelected(f);
  }

  return (
    <div className="px-6 py-4 space-y-4">
      <div className="space-y-2">
        <Label htmlFor="doc-title">Document title</Label>
        <Input
          id="doc-title"
          value={title}
          onChange={(e) => onTitleChange(e.target.value)}
          placeholder="e.g. Berth A-12 Sales Contract - John Smith"
        />
      </div>
      <div
        onDragOver={(e) => {
          e.preventDefault();
          setDragging(true);
        }}
        onDragLeave={() => setDragging(false)}
        onDrop={(e) => {
          e.preventDefault();
          setDragging(false);
          const f = e.dataTransfer.files[0];
          if (f) handleFile(f);
        }}
        className={
          'rounded-lg border-2 border-dashed p-12 text-center transition-colors cursor-pointer ' +
          (dragging
            ? 'border-foreground bg-muted/40'
            : 'border-muted-foreground/30 hover:border-muted-foreground/60')
        }
        onClick={() => inputRef.current?.click()}
        role="button"
        tabIndex={0}
        onKeyDown={(e) => {
          if (e.key === 'Enter' || e.key === ' ') {
            e.preventDefault();
            inputRef.current?.click();
          }
        }}
      >
        <p className="text-sm text-foreground font-medium">Drop a PDF here, or click to browse</p>
        <p className="text-xs text-muted-foreground mt-1">Max 50 MB.</p>
        <input
          ref={inputRef}
          type="file"
          accept="application/pdf,.pdf"
          className="hidden"
          onChange={(e) => {
            const f = e.target.files?.[0];
            if (f) handleFile(f);
          }}
        />
      </div>
    </div>
  );
}

// ─── Step 2: recipient configurator ───────────────────────────────

function RecipientsStep({
  recipients,
  onChange,
  title,
  onTitleChange,
  invitationMessage,
  onInvitationMessageChange,
}: {
  recipients: Recipient[];
  onChange: (next: Recipient[]) => void;
  title: string;
  onTitleChange: (t: string) => void;
  invitationMessage: string;
  onInvitationMessageChange: (next: string) => void;
}) {
  function update(i: number, patch: Partial<Recipient>) {
    const next = [...recipients];
    next[i] = { ...next[i]!, ...patch };
    onChange(next);
  }
  function remove(i: number) {
    const next = recipients.filter((_, idx) => idx !== i);
    // Reflow signingOrder
    onChange(next.map((r, idx) => ({ ...r, signingOrder: idx + 1 })));
  }
  function add() {
    onChange([
      ...recipients,
      {
        name: '',
        email: '',
        role: 'SIGNER',
        signingOrder: recipients.length + 1,
      },
    ]);
  }
  return (
    <div className="px-6 py-4 space-y-4 overflow-y-auto">
      <div className="space-y-2">
        <Label htmlFor="doc-title-step2">Document title</Label>
        <Input id="doc-title-step2" value={title} onChange={(e) => onTitleChange(e.target.value)} />
      </div>
      <div className="space-y-2">
        <Label>Recipients (in signing order)</Label>
        <div className="space-y-2">
          {recipients.map((r, i) => (
            <div key={i} className="flex gap-2 items-center">
              <span className="w-8 shrink-0 text-xs text-center text-muted-foreground tabular-nums">
                #{r.signingOrder}
              </span>
              <Input
                className="flex-1 min-w-0"
                placeholder="Name"
                value={r.name}
                onChange={(e) => update(i, { name: e.target.value })}
              />
              <Input
                className="flex-[2] min-w-0"
                placeholder="email@example.com"
                type="email"
                value={r.email}
                onChange={(e) => update(i, { email: e.target.value })}
              />
              <Select
                value={r.role}
                onValueChange={(v) => update(i, { role: v as Recipient['role'] })}
              >
                <SelectTrigger className="w-40 shrink-0">
                  <SelectValue />
                </SelectTrigger>
                <SelectContent>
                  <SelectItem value="SIGNER">Signer</SelectItem>
                  <SelectItem value="APPROVER">Approver</SelectItem>
                  <SelectItem value="CC">CC (no signing)</SelectItem>
                </SelectContent>
              </Select>
              <Button
                type="button"
                variant="ghost"
                size="icon"
                onClick={() => remove(i)}
                aria-label="Remove recipient"
                className="shrink-0"
              >
                <Trash2 className="size-4" aria-hidden />
              </Button>
            </div>
          ))}
        </div>
        <Button type="button" variant="outline" size="sm" onClick={add} className="gap-1.5">
          <Plus className="size-4" aria-hidden /> Add recipient
        </Button>
      </div>
      <div className="space-y-2">
        <Label htmlFor="invitation-message">
          Optional message to include in the signing invitation
        </Label>
        <textarea
          id="invitation-message"
          value={invitationMessage}
          onChange={(e) => onInvitationMessageChange(e.target.value)}
          placeholder="Hi John, please review the attached contract before signing. Reach out if anything needs adjusting."
          rows={6}
          maxLength={1000}
          className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-none"
        />
        <p className="text-xs text-muted-foreground">
          Appears above the Sign button in every recipient&rsquo;s invitation email. Plain text
          only; 1000 characters max.
        </p>
      </div>
    </div>
  );
}

// ─── Step 3: field placement overlay ──────────────────────────────

function FieldPlacementStep({
  fileBytes,
  fields,
  onFieldsChange,
  recipients,
  selectedFieldId,
  onSelectField,
  isDetecting,
}: {
  fileBytes: Uint8Array;
  fields: PlacedField[];
  onFieldsChange: (next: PlacedField[]) => void;
  recipients: Recipient[];
  selectedFieldId: string | null;
  onSelectField: (id: string | null) => void;
  isDetecting: boolean;
}) {
  const [numPages, setNumPages] = useState(1);
  const [pageNumber, setPageNumber] = useState(1);
  const [placingType, setPlacingType] = useState<FieldType | null>(null);
  // PDF render zoom - defaults to 1 (the historical fixed scale). Buttons
  // below the page-nav let reps zoom out for an overview or zoom in for
  // tight placement work. Field coordinates stay in % of page dimensions
  // so the placed-field overlay scales automatically with the PDF.
  const [pdfScale, setPdfScale] = useState(1);
  // Surfaces a useful error message when the PDF fails to load (CORS
  // mismatch, malformed file, worker init failure). Previously the
  // dialog showed an infinite spinner that gave reps no signal to act on.
  const [pdfLoadError, setPdfLoadError] = useState<string | null>(null);
  const pageContainerRef = useRef<HTMLDivElement>(null);

  // react-pdf re-creates its internal PDF document whenever the `file`
  // prop's reference identity changes, so the `{ data }` object MUST
  // be memoized - otherwise every render restarts parsing from scratch
  // and flickers the placeholder.
  const pdfFileSource = useMemo(() => ({ data: fileBytes }), [fileBytes]);

  const pageFields = useMemo(
    () => fields.filter((f) => f.pageNumber === pageNumber),
    [fields, pageNumber],
  );

  function placeFieldAt(clientX: number, clientY: number, container: HTMLElement) {
    if (!placingType) return;
    const rect = container.getBoundingClientRect();
    const pageX = ((clientX - rect.left) / rect.width) * 100;
    const pageY = ((clientY - rect.top) / rect.height) * 100;
    const defaults = FIELD_DEFAULTS[placingType];
    const newField: PlacedField = {
      id: `f-${Math.random().toString(36).slice(2, 10)}`,
      type: placingType,
      recipientIndex: 0,
      pageNumber,
      pageX: Math.max(0, Math.min(100 - defaults.widthPct, pageX - defaults.widthPct / 2)),
      pageY: Math.max(0, Math.min(100 - defaults.heightPct, pageY - defaults.heightPct / 2)),
      pageWidth: defaults.widthPct,
      pageHeight: defaults.heightPct,
    };
    onFieldsChange([...fields, newField]);
    onSelectField(newField.id);
    setPlacingType(null);
  }

  function updateField(id: string, patch: Partial<PlacedField>) {
    onFieldsChange(fields.map((f) => (f.id === id ? { ...f, ...patch } : f)));
  }
  function removeField(id: string) {
    onFieldsChange(fields.filter((f) => f.id !== id));
    if (selectedFieldId === id) onSelectField(null);
  }

  // Keyboard shortcuts on the placement canvas - Delete / Backspace
  // removes the selected field; arrow keys nudge it by 0.5% (Shift = 5%
  // for coarser moves). Listens at document level so the handler still
  // fires when the rep's focus is on the PDF canvas (which doesn't take
  // text input).
  useEffect(() => {
    function onKey(e: KeyboardEvent) {
      if (!selectedFieldId) return;
      // Ignore when the focus is on a real input / textarea so the
      // shortcuts never steal typing.
      const tgt = e.target as HTMLElement | null;
      if (
        tgt &&
        (tgt.tagName === 'INPUT' ||
          tgt.tagName === 'TEXTAREA' ||
          tgt.tagName === 'SELECT' ||
          tgt.isContentEditable)
      ) {
        return;
      }
      const field = fields.find((f) => f.id === selectedFieldId);
      if (!field) return;
      if (e.key === 'Delete' || e.key === 'Backspace') {
        e.preventDefault();
        removeField(selectedFieldId);
        return;
      }
      if (e.key.startsWith('Arrow')) {
        const step = e.shiftKey ? 5 : 0.5;
        const patch: Partial<PlacedField> = {};
        if (e.key === 'ArrowUp') patch.pageY = Math.max(0, field.pageY - step);
        if (e.key === 'ArrowDown')
          patch.pageY = Math.min(100 - field.pageHeight, field.pageY + step);
        if (e.key === 'ArrowLeft') patch.pageX = Math.max(0, field.pageX - step);
        if (e.key === 'ArrowRight')
          patch.pageX = Math.min(100 - field.pageWidth, field.pageX + step);
        if (Object.keys(patch).length > 0) {
          e.preventDefault();
          updateField(selectedFieldId, patch);
        }
      }
    }
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [selectedFieldId, fields, onFieldsChange]); // eslint-disable-line react-hooks/exhaustive-deps

  return (
    <div className="flex-1 flex overflow-hidden">
      {/* Field palette */}
      <div className="w-44 border-r p-3 flex-shrink-0 overflow-y-auto bg-muted/30">
        <p className="text-xs font-medium text-muted-foreground mb-2">Field palette</p>
        <div className="space-y-1">
          {(Object.keys(FIELD_DEFAULTS) as FieldType[])
            .filter((t) => t !== 'FREE_SIGNATURE') // collapsed with SIGNATURE for the palette
            .map((t) => {
              const def = FIELD_DEFAULTS[t];
              const Icon = def.icon;
              return (
                <button
                  key={t}
                  type="button"
                  onClick={() => setPlacingType(t === placingType ? null : t)}
                  className={
                    'w-full text-left text-xs px-2 py-1.5 rounded flex items-center gap-2 transition ' +
                    (placingType === t
                      ? 'bg-foreground text-background'
                      : 'hover:bg-background border border-transparent hover:border-border')
                  }
                >
                  <Icon className="size-3.5" aria-hidden />
                  {def.label}
                </button>
              );
            })}
        </div>
        {placingType && (
          <p className="mt-3 text-xs text-muted-foreground">
            Click on the PDF to place a {FIELD_DEFAULTS[placingType].label.toLowerCase()}.
          </p>
        )}
        <hr className="my-3 border-muted-foreground/20" />
        <p className="text-xs font-medium text-muted-foreground mb-2">Recipients</p>
        <div className="space-y-2">
          {recipients.map((r, i) => (
            <div key={i} className="flex items-start gap-2 text-xs">
              <span
                className="mt-1 size-2 rounded-full shrink-0"
                style={{ backgroundColor: RECIPIENT_COLORS[i % RECIPIENT_COLORS.length] }}
                aria-hidden
              />
              <div className="min-w-0 flex-1">
                <div className="flex items-center gap-1.5">
                  <span className="truncate font-medium">{r.name || `#${r.signingOrder}`}</span>
                  <RecipientRoleBadge role={r.role} />
                </div>
                {/* Email shown separately from name so reps with duplicate
                    names (e.g. multiple "Matt" recipients) can still tell
                    rows apart. Empty/blank emails fall back to a muted
                    placeholder so the row doesn't shift size. */}
                <p className="truncate text-muted-foreground" title={r.email}>
                  {r.email || 'no email set'}
                </p>
              </div>
            </div>
          ))}
        </div>
      </div>

      {/* PDF + overlay */}
      <div className="flex-1 flex flex-col overflow-hidden">
        <div className="flex items-center gap-2 border-b bg-muted/20 px-3 py-2 text-sm">
          <Button
            type="button"
            variant="ghost"
            size="icon"
            disabled={pageNumber <= 1}
            onClick={() => setPageNumber((p) => Math.max(1, p - 1))}
            aria-label="Previous page"
          >
            <ChevronLeft className="size-4" />
          </Button>
          <span className="min-w-[80px] text-center tabular-nums">
            {pageNumber} / {numPages}
          </span>
          <Button
            type="button"
            variant="ghost"
            size="icon"
            disabled={pageNumber >= numPages}
            onClick={() => setPageNumber((p) => Math.min(numPages, p + 1))}
            aria-label="Next page"
          >
            <ChevronRight className="size-4" />
          </Button>
          {/* Zoom controls - render zoom only, field coordinates stay
              in % so placements scale automatically with the canvas. */}
          <div className="ml-3 flex items-center gap-1 border-l pl-3">
            <Button
              type="button"
              variant="ghost"
              size="icon"
              disabled={pdfScale <= 0.5}
              onClick={() => setPdfScale((s) => Math.max(0.5, Math.round((s - 0.25) * 100) / 100))}
              aria-label="Zoom out"
            >
              <span className="text-base font-bold leading-none">−</span>
            </Button>
            <span className="min-w-[44px] text-center text-xs tabular-nums text-muted-foreground">
              {Math.round(pdfScale * 100)}%
            </span>
            <Button
              type="button"
              variant="ghost"
              size="icon"
              disabled={pdfScale >= 2}
              onClick={() => setPdfScale((s) => Math.min(2, Math.round((s + 0.25) * 100) / 100))}
              aria-label="Zoom in"
            >
              <span className="text-base font-bold leading-none">+</span>
            </Button>
          </div>
          {isDetecting && (
            <span className="ml-3 flex items-center gap-1.5 text-xs text-muted-foreground">
              <Loader2 className="size-3 animate-spin" /> Auto-detecting fields…
            </span>
          )}
          <span className="ml-auto text-xs text-muted-foreground tabular-nums">
            {fields.length} {fields.length === 1 ? 'field' : 'fields'} placed
          </span>
        </div>
        <div className="flex-1 overflow-auto bg-muted/30 p-4">
          <div
            ref={pageContainerRef}
            className="relative mx-auto bg-white shadow"
            style={{ width: 'fit-content', cursor: placingType ? 'crosshair' : 'default' }}
            onClick={(e) => {
              if (!placingType) return;
              if (!pageContainerRef.current) return;
              // Bail when the click landed on an existing field (we
              // handle those via the field's own onClick).
              if ((e.target as HTMLElement).closest('[data-field-id]')) return;
              placeFieldAt(e.clientX, e.clientY, pageContainerRef.current);
            }}
          >
            {pdfLoadError ? (
              <div className="flex h-96 w-[600px] flex-col items-center justify-center gap-2 text-center text-sm text-muted-foreground">
                <p className="font-medium text-destructive">PDF preview failed to load</p>
                <p className="font-mono text-xs break-all">{pdfLoadError}</p>
                <p className="text-xs">
                  Field placement still works once the file is uploaded; placements drag onto a
                  blank canvas. Re-pick the file from step 1 to retry.
                </p>
              </div>
            ) : (
              <Document
                // Passing { data } gives PDF.js the raw bytes directly,
                // so its (cross-origin) Web Worker doesn't have to fetch
                // anything - this is the only way to make react-pdf work
                // when the worker is loaded from a CDN.
                file={pdfFileSource}
                onLoadSuccess={({ numPages: n }) => {
                  setNumPages(n);
                  setPdfLoadError(null);
                }}
                onLoadError={(err) => {
                  setPdfLoadError(err instanceof Error ? err.message : 'Unknown error');
                }}
                loading={
                  <div className="flex h-64 w-96 items-center justify-center text-sm text-muted-foreground">
                    <Loader2 className="size-4 mr-2 animate-spin" /> Loading PDF…
                  </div>
                }
              >
                <Page
                  pageNumber={pageNumber}
                  scale={pdfScale}
                  renderAnnotationLayer={false}
                  renderTextLayer={false}
                />
              </Document>
            )}
            {/* Overlay layer */}
            <div className="absolute inset-0 pointer-events-none">
              {pageFields.map((field) => (
                <FieldOverlay
                  key={field.id}
                  field={field}
                  selected={selectedFieldId === field.id}
                  recipients={recipients}
                  onSelect={() => onSelectField(field.id)}
                  onUpdate={(patch) => updateField(field.id, patch)}
                  onRemove={() => removeField(field.id)}
                />
              ))}
            </div>
          </div>
        </div>
      </div>

      {/* Side panel for selected field */}
      {selectedFieldId && (
        <FieldSidePanel
          field={fields.find((f) => f.id === selectedFieldId)!}
          recipients={recipients}
          onUpdate={(patch) => updateField(selectedFieldId, patch)}
          onRemove={() => removeField(selectedFieldId)}
          onClose={() => onSelectField(null)}
        />
      )}
    </div>
  );
}

function FieldOverlay({
  field,
  selected,
  recipients,
  onSelect,
  onUpdate,
  onRemove,
}: {
  field: PlacedField;
  selected: boolean;
  recipients: Recipient[];
  onSelect: () => void;
  onUpdate: (patch: Partial<PlacedField>) => void;
  onRemove: () => void;
}) {
  const Icon = FIELD_DEFAULTS[field.type].icon;
  const color = RECIPIENT_COLORS[field.recipientIndex % RECIPIENT_COLORS.length];
  const recipient = recipients[field.recipientIndex];

  // Drag handler - translate mouse-move pixels into percent deltas
  // against the parent container's bounding rect.
  function startDrag(e: React.MouseEvent) {
    e.preventDefault();
    e.stopPropagation();
    onSelect();
    const container = (e.currentTarget.parentElement?.parentElement as HTMLElement) ?? null;
    if (!container) return;
    const rect = container.getBoundingClientRect();
    const startX = e.clientX;
    const startY = e.clientY;
    const startPageX = field.pageX;
    const startPageY = field.pageY;

    function onMove(ev: MouseEvent) {
      const dxPct = ((ev.clientX - startX) / rect.width) * 100;
      const dyPct = ((ev.clientY - startY) / rect.height) * 100;
      onUpdate({
        pageX: Math.max(0, Math.min(100 - field.pageWidth, startPageX + dxPct)),
        pageY: Math.max(0, Math.min(100 - field.pageHeight, startPageY + dyPct)),
      });
    }
    function onUp() {
      window.removeEventListener('mousemove', onMove);
      window.removeEventListener('mouseup', onUp);
    }
    window.addEventListener('mousemove', onMove);
    window.addEventListener('mouseup', onUp);
  }

  return (
    <div
      data-field-id={field.id}
      onClick={(e) => {
        e.stopPropagation();
        onSelect();
      }}
      onMouseDown={startDrag}
      className={
        'absolute pointer-events-auto cursor-move rounded border-2 text-xs flex items-center gap-1 px-1 ' +
        (selected ? 'ring-2 ring-offset-1 ring-foreground' : '')
      }
      style={{
        left: `${field.pageX}%`,
        top: `${field.pageY}%`,
        width: `${field.pageWidth}%`,
        height: `${field.pageHeight}%`,
        backgroundColor: color + '22',
        borderColor: color,
      }}
      role="button"
      tabIndex={0}
      aria-label={`${FIELD_DEFAULTS[field.type].label} for ${recipient?.name ?? 'unassigned'}`}
    >
      <Icon className="size-3 shrink-0" aria-hidden style={{ color }} />
      <span className="truncate" style={{ color }}>
        {FIELD_DEFAULTS[field.type].label}
      </span>
      {selected && (
        <button
          type="button"
          onClick={(e) => {
            e.stopPropagation();
            onRemove();
          }}
          className="ml-auto rounded p-0.5 hover:bg-background/60"
          aria-label="Delete field"
        >
          <X className="size-3" aria-hidden />
        </button>
      )}
    </div>
  );
}

function FieldSidePanel({
  field,
  recipients,
  onUpdate,
  onRemove,
  onClose,
}: {
  field: PlacedField;
  recipients: Recipient[];
  onUpdate: (patch: Partial<PlacedField>) => void;
  onRemove: () => void;
  onClose: () => void;
}) {
  return (
    <div className="w-64 border-l p-4 flex-shrink-0 bg-background space-y-3 overflow-y-auto">
      <div className="flex items-center justify-between">
        <p className="text-sm font-medium">Field properties</p>
        <Button variant="ghost" size="icon" onClick={onClose} aria-label="Close panel">
          <X className="size-4" aria-hidden />
        </Button>
      </div>
      <div className="space-y-1.5">
        <Label className="text-xs">Type</Label>
        <Select value={field.type} onValueChange={(v) => onUpdate({ type: v as FieldType })}>
          <SelectTrigger>
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            {(Object.keys(FIELD_DEFAULTS) as FieldType[]).map((t) => (
              <SelectItem key={t} value={t}>
                {FIELD_DEFAULTS[t].label}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
      </div>
      <div className="space-y-1.5">
        <Label className="text-xs">Assign this field to</Label>
        <Select
          value={String(field.recipientIndex)}
          onValueChange={(v) => onUpdate({ recipientIndex: Number(v) })}
        >
          <SelectTrigger>
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            {recipients.map((r, i) => (
              <SelectItem key={i} value={String(i)}>
                <span className="flex flex-col items-start gap-0.5 text-left">
                  <span className="flex items-center gap-1.5">
                    <span className="font-medium">
                      #{r.signingOrder} {r.name || `Recipient ${r.signingOrder}`}
                    </span>
                    <RecipientRoleBadge role={r.role} />
                  </span>
                  {r.email ? (
                    <span className="text-xs text-muted-foreground">{r.email}</span>
                  ) : null}
                </span>
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
        <p className="text-xs text-muted-foreground">
          Whoever is selected here is the only person who will see and fill this field when the
          document is sent for signing.
        </p>
      </div>
      <div className="grid grid-cols-2 gap-2">
        <div className="space-y-1">
          <Label className="text-xs">Width %</Label>
          <Input
            type="number"
            value={field.pageWidth.toFixed(1)}
            onChange={(e) => onUpdate({ pageWidth: Number(e.target.value) })}
            step="0.5"
            min="1"
            max="100"
          />
        </div>
        <div className="space-y-1">
          <Label className="text-xs">Height %</Label>
          <Input
            type="number"
            value={field.pageHeight.toFixed(1)}
            onChange={(e) => onUpdate({ pageHeight: Number(e.target.value) })}
            step="0.5"
            min="1"
            max="100"
          />
        </div>
      </div>
      <FieldMetaSubPanel field={field} onUpdate={onUpdate} />

      <Button variant="destructive" size="sm" onClick={onRemove} className="w-full gap-1.5">
        <Trash2 className="size-4" aria-hidden /> Delete field
      </Button>
    </div>
  );
}

/**
 * Editable list for CHECKBOX / RADIO / DROPDOWN options. Kept top-level
 * so React doesn't recreate the component subtree on every keystroke
 * (react-hooks/static-components rule). Single-select variants render
 * radio inputs that mutually exclude defaults; multi-select renders
 * checkboxes.
 */
function ChoiceMetaEditor({
  options,
  onChange,
  singleSelect,
}: {
  options: Array<{ value: string; checked?: boolean }>;
  onChange: (next: Array<{ value: string; checked?: boolean }>) => void;
  singleSelect: boolean;
}) {
  return (
    <div className="space-y-1.5">
      <div className="flex items-center justify-between">
        <Label className="text-xs">Options</Label>
        <Button
          type="button"
          size="sm"
          variant="ghost"
          className="h-6 text-xs"
          onClick={() =>
            onChange([...options, { value: `Option ${options.length + 1}`, checked: false }])
          }
        >
          + Add
        </Button>
      </div>
      {options.length === 0 ? (
        <p className="text-xs text-muted-foreground">No options yet, add at least one.</p>
      ) : (
        <ul className="space-y-1">
          {options.map((opt, idx) => (
            <li key={idx} className="flex items-center gap-1.5">
              <input
                type={singleSelect ? 'radio' : 'checkbox'}
                name="choice-meta"
                checked={!!opt.checked}
                onChange={() => {
                  const next = options.map((o, i) =>
                    singleSelect
                      ? { ...o, checked: i === idx }
                      : i === idx
                        ? { ...o, checked: !o.checked }
                        : o,
                  );
                  onChange(next);
                }}
                aria-label={`${opt.value} default ${singleSelect ? 'selection' : 'checked'}`}
                className="size-3.5 shrink-0"
              />
              <Input
                value={opt.value}
                onChange={(e) => {
                  const next = options.map((o, i) =>
                    i === idx ? { ...o, value: e.target.value } : o,
                  );
                  onChange(next);
                }}
                className="h-7 text-xs"
              />
              <Button
                type="button"
                size="sm"
                variant="ghost"
                className="h-6 w-6 p-0 shrink-0"
                onClick={() => onChange(options.filter((_, i) => i !== idx))}
                aria-label="Remove option"
              >
                <X className="size-3" aria-hidden />
              </Button>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

/**
 * Per-field-type config (Documenso v2 fieldMeta). Surfaces inputs only
 * when the field type carries per-instance config; SIGNATURE / INITIALS /
 * DATE / EMAIL / NAME fields render nothing here. Edits write into
 * `field.fieldMeta` shallowly so the v2 create-many payload receives
 * the shape Documenso expects.
 */
function FieldMetaSubPanel({
  field,
  onUpdate,
}: {
  field: PlacedField;
  onUpdate: (patch: Partial<PlacedField>) => void;
}) {
  const meta: Record<string, unknown> = (field.fieldMeta as Record<string, unknown>) ?? {};
  function patchMeta(diff: Record<string, unknown>) {
    onUpdate({ fieldMeta: { ...meta, ...diff } });
  }

  if (field.type === 'TEXT') {
    return (
      <div className="space-y-2 rounded-md border bg-muted/30 p-2">
        <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
          Text settings
        </p>
        <div className="space-y-1">
          <Label className="text-xs">Default text</Label>
          <Input
            value={typeof meta.text === 'string' ? meta.text : ''}
            onChange={(e) => patchMeta({ text: e.target.value })}
            className="h-7 text-xs"
            placeholder="Pre-filled value the signer sees"
          />
        </div>
        <div className="space-y-1">
          <Label className="text-xs">Label</Label>
          <Input
            value={typeof meta.label === 'string' ? meta.label : ''}
            onChange={(e) => patchMeta({ label: e.target.value })}
            className="h-7 text-xs"
            placeholder="Helper text above the field"
          />
        </div>
        <label className="flex items-center gap-2 text-xs">
          <input
            type="checkbox"
            checked={meta.required === true}
            onChange={(e) => patchMeta({ required: e.target.checked })}
            className="size-3.5"
          />
          Required
        </label>
      </div>
    );
  }

  if (field.type === 'NUMBER') {
    return (
      <div className="space-y-2 rounded-md border bg-muted/30 p-2">
        <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
          Number settings
        </p>
        <div className="space-y-1">
          <Label className="text-xs">Format</Label>
          <Input
            value={typeof meta.numberFormat === 'string' ? meta.numberFormat : ''}
            onChange={(e) => patchMeta({ numberFormat: e.target.value })}
            className="h-7 text-xs"
            placeholder="e.g. 0.00, $#,##0"
          />
        </div>
        <div className="grid grid-cols-2 gap-1.5">
          <div className="space-y-1">
            <Label className="text-xs">Min</Label>
            <Input
              type="number"
              value={typeof meta.min === 'number' ? meta.min : ''}
              onChange={(e) =>
                patchMeta({ min: e.target.value === '' ? undefined : Number(e.target.value) })
              }
              className="h-7 text-xs"
            />
          </div>
          <div className="space-y-1">
            <Label className="text-xs">Max</Label>
            <Input
              type="number"
              value={typeof meta.max === 'number' ? meta.max : ''}
              onChange={(e) =>
                patchMeta({ max: e.target.value === '' ? undefined : Number(e.target.value) })
              }
              className="h-7 text-xs"
            />
          </div>
        </div>
        <label className="flex items-center gap-2 text-xs">
          <input
            type="checkbox"
            checked={meta.required === true}
            onChange={(e) => patchMeta({ required: e.target.checked })}
            className="size-3.5"
          />
          Required
        </label>
      </div>
    );
  }

  if (field.type === 'CHECKBOX' || field.type === 'RADIO' || field.type === 'DROPDOWN') {
    const rawValues = (meta.values as Array<{ value: string; checked?: boolean }>) ?? [];
    return (
      <div className="space-y-2 rounded-md border bg-muted/30 p-2">
        <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
          {field.type === 'CHECKBOX'
            ? 'Checkbox options'
            : field.type === 'RADIO'
              ? 'Radio options'
              : 'Dropdown options'}
        </p>
        <ChoiceMetaEditor
          options={rawValues}
          onChange={(next) => patchMeta({ values: next })}
          singleSelect={field.type !== 'CHECKBOX'}
        />
      </div>
    );
  }

  // SIGNATURE / INITIALS / DATE / EMAIL / NAME carry no per-instance
  // configuration in Documenso v2 today.
  return null;
}
