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

import { db } from '@/lib/db';
import { documentTemplates, documents, files } from '@/lib/db/schema/documents';
import type { File as DbFile, Document as DbDocument } from '@/lib/db/schema/documents';
import { clients, clientContacts } from '@/lib/db/schema/clients';
import { interests } from '@/lib/db/schema/interests';
import { berths } from '@/lib/db/schema/berths';
import { ports } from '@/lib/db/schema/ports';
import { yachts } from '@/lib/db/schema/yachts';
import { buildListQuery } from '@/lib/db/query-builder';
import { createAuditLog } from '@/lib/audit';
import { diffEntity } from '@/lib/entity-diff';
import { NotFoundError, ValidationError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
import { minioClient, buildStoragePath } from '@/lib/minio';
import { env } from '@/lib/env';
import { logger } from '@/lib/logger';
import { generatePdf } from '@/lib/pdf/generate';
import { getCountryName } from '@/lib/i18n/countries';
import {
  createDocument as documensoCreate,
  sendDocument as documensoSend,
  generateDocumentFromTemplate as documensoGenerateFromTemplate,
} from '@/lib/services/documenso-client';
import { buildDocumensoPayload } from '@/lib/services/documenso-payload';
import { generateEoiPdfFromTemplate } from '@/lib/pdf/fill-eoi-form';
import { MERGE_FIELDS, type MergeFieldCatalog } from '@/lib/templates/merge-fields';
import { buildEoiContext } from '@/lib/services/eoi-context';
import { sendEmail } from '@/lib/email';
import type {
  CreateTemplateInput,
  UpdateTemplateInput,
  ListTemplatesInput,
  GenerateInput,
  GenerateAndSignInput,
} from '@/lib/validators/document-templates';

// ─── Types ────────────────────────────────────────────────────────────────────

interface AuditMeta {
  userId: string;
  portId: string;
  ipAddress: string;
  userAgent: string;
}

// ─── Merge Field Definitions ──────────────────────────────────────────────────

export function getMergeFields(): MergeFieldCatalog {
  return MERGE_FIELDS;
}

// ─── List ─────────────────────────────────────────────────────────────────────

export async function listTemplates(portId: string, query: ListTemplatesInput) {
  const { page, limit, sort, order, search, templateType, isActive } = query;

  const filters = [];

  if (templateType) {
    filters.push(eq(documentTemplates.templateType, templateType));
  }
  if (isActive !== undefined) {
    filters.push(eq(documentTemplates.isActive, isActive));
  }

  const sortColumn =
    sort === 'name'
      ? documentTemplates.name
      : sort === 'templateType'
        ? documentTemplates.templateType
        : sort === 'createdAt'
          ? documentTemplates.createdAt
          : documentTemplates.updatedAt;

  return buildListQuery({
    table: documentTemplates,
    portIdColumn: documentTemplates.portId,
    portId,
    idColumn: documentTemplates.id,
    updatedAtColumn: documentTemplates.updatedAt,
    searchColumns: [documentTemplates.name],
    searchTerm: search,
    filters,
    sort: { column: sortColumn, direction: order },
    page,
    pageSize: limit,
  });
}

// ─── Get by ID ────────────────────────────────────────────────────────────────

export async function getTemplateById(id: string, portId: string) {
  const template = await db.query.documentTemplates.findFirst({
    where: eq(documentTemplates.id, id),
  });

  if (!template || template.portId !== portId) {
    throw new NotFoundError('Document template');
  }

  return template;
}

// ─── Create ───────────────────────────────────────────────────────────────────

export async function createTemplate(portId: string, data: CreateTemplateInput, meta: AuditMeta) {
  const [template] = await db
    .insert(documentTemplates)
    .values({
      portId,
      name: data.name,
      description: data.description ?? null,
      templateType: data.templateType,
      bodyHtml: data.bodyHtml,
      mergeFields: data.mergeFields ?? [],
      isActive: data.isActive ?? true,
      createdBy: meta.userId,
    })
    .returning();

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'create',
    entityType: 'documentTemplate',
    entityId: template!.id,
    newValue: { name: template!.name, templateType: template!.templateType },
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  emitToRoom(`port:${portId}`, 'documentTemplate:created', { templateId: template!.id });

  return template!;
}

// ─── Update ───────────────────────────────────────────────────────────────────

export async function updateTemplate(
  id: string,
  portId: string,
  data: UpdateTemplateInput,
  meta: AuditMeta,
) {
  const existing = await getTemplateById(id, portId);

  const { diff } = diffEntity(existing as Record<string, unknown>, data as Record<string, unknown>);

  const [updated] = await db
    .update(documentTemplates)
    .set({ ...data, updatedAt: new Date() })
    .where(and(eq(documentTemplates.id, id), eq(documentTemplates.portId, portId)))
    .returning();

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'update',
    entityType: 'documentTemplate',
    entityId: id,
    oldValue: diff as Record<string, unknown>,
    newValue: data as Record<string, unknown>,
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  emitToRoom(`port:${portId}`, 'documentTemplate:updated', { templateId: id });

  return updated!;
}

// ─── Delete ───────────────────────────────────────────────────────────────────

export async function deleteTemplate(id: string, portId: string, meta: AuditMeta) {
  const existing = await getTemplateById(id, portId);

  await db
    .delete(documentTemplates)
    .where(and(eq(documentTemplates.id, id), eq(documentTemplates.portId, portId)));

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'delete',
    entityType: 'documentTemplate',
    entityId: id,
    oldValue: { name: existing.name, templateType: existing.templateType },
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  emitToRoom(`port:${portId}`, 'documentTemplate:deleted', { templateId: id });
}

// ─── Resolve Template ─────────────────────────────────────────────────────────

/**
 * Interpolates all {{entity.field}} tokens in the template body HTML.
 * BR-140: Required merge fields with no value throw ValidationError.
 */
export async function resolveTemplate(
  templateId: string,
  context: {
    clientId?: string;
    interestId?: string;
    berthId?: string;
    portId: string;
  },
): Promise<string> {
  const template = await getTemplateById(templateId, context.portId);

  // Build token→value map from context
  const tokenMap: Record<string, string> = {};

  // Date tokens
  const now = new Date();
  tokenMap['{{date.today}}'] = now.toLocaleDateString('en-GB');
  tokenMap['{{date.year}}'] = String(now.getFullYear());

  // Port tokens
  const port = await db.query.ports.findFirst({ where: eq(ports.id, context.portId) });
  if (port) {
    tokenMap['{{port.name}}'] = port.name;
    tokenMap['{{port.defaultCurrency}}'] = port.defaultCurrency;
  }

  // ─── EOI-style resolution ───────────────────────────────────────────────────
  // If an interestId is provided, prefer the shared buildEoiContext payload so
  // that yacht.*, company.*, owner.*, and berth.* tokens all resolve from the
  // same denormalised snapshot the PDF/Documenso pipelines use.
  // Falls back to the legacy path below if the interest isn't EOI-ready
  // (missing yacht or berth), so non-EOI templates still work.
  let eoiContextLoaded = false;
  if (context.interestId) {
    try {
      const eoi = await buildEoiContext(context.interestId, context.portId);
      eoiContextLoaded = true;

      // Client tokens (from EoiContext)
      tokenMap['{{client.fullName}}'] = eoi.client.fullName;
      tokenMap['{{client.email}}'] = eoi.client.primaryEmail ?? '';
      tokenMap['{{client.phone}}'] = eoi.client.primaryPhone ?? '';
      tokenMap['{{client.nationality}}'] = eoi.client.nationality ?? '';

      // Yacht tokens
      tokenMap['{{yacht.name}}'] = eoi.yacht.name;
      tokenMap['{{yacht.hullNumber}}'] = eoi.yacht.hullNumber ?? '';
      tokenMap['{{yacht.flag}}'] = eoi.yacht.flag ?? '';
      tokenMap['{{yacht.yearBuilt}}'] =
        eoi.yacht.yearBuilt != null ? String(eoi.yacht.yearBuilt) : '';
      tokenMap['{{yacht.lengthFt}}'] = eoi.yacht.lengthFt ?? '';
      tokenMap['{{yacht.widthFt}}'] = eoi.yacht.widthFt ?? '';
      tokenMap['{{yacht.draftFt}}'] = eoi.yacht.draftFt ?? '';
      tokenMap['{{yacht.lengthM}}'] = eoi.yacht.lengthM ?? '';
      tokenMap['{{yacht.widthM}}'] = eoi.yacht.widthM ?? '';
      tokenMap['{{yacht.draftM}}'] = eoi.yacht.draftM ?? '';

      // EoiContext doesn't expose the yacht.registration column — look it up
      // separately (cheap, indexed fetch) so the token resolves when present.
      try {
        const interestRow = await db.query.interests.findFirst({
          where: eq(interests.id, context.interestId),
          columns: { yachtId: true },
        });
        if (interestRow?.yachtId) {
          const yachtRow = await db.query.yachts.findFirst({
            where: eq(yachts.id, interestRow.yachtId),
            columns: { registration: true },
          });
          tokenMap['{{yacht.registration}}'] = yachtRow?.registration ?? '';
        } else {
          tokenMap['{{yacht.registration}}'] = '';
        }
      } catch {
        tokenMap['{{yacht.registration}}'] = '';
      }

      // Company tokens (only populated when owner is a company)
      tokenMap['{{company.name}}'] = eoi.company?.name ?? '';
      tokenMap['{{company.legalName}}'] = eoi.company?.legalName ?? '';
      tokenMap['{{company.taxId}}'] = eoi.company?.taxId ?? '';
      tokenMap['{{company.billingAddress}}'] = eoi.company?.billingAddress ?? '';

      // Owner tokens
      tokenMap['{{owner.type}}'] = eoi.owner.type;
      tokenMap['{{owner.name}}'] = eoi.owner.name;
      tokenMap['{{owner.legalName}}'] = eoi.owner.legalName ?? '';

      // Berth tokens (from EoiContext)
      tokenMap['{{berth.mooringNumber}}'] = eoi.berth.mooringNumber;
      tokenMap['{{berth.area}}'] = eoi.berth.area ?? '';
      tokenMap['{{berth.lengthFt}}'] = eoi.berth.lengthFt ?? '';
      tokenMap['{{berth.price}}'] = eoi.berth.price ?? '';
      tokenMap['{{berth.priceCurrency}}'] = eoi.berth.priceCurrency;
      tokenMap['{{berth.tenureType}}'] = eoi.berth.tenureType;

      // Interest tokens
      tokenMap['{{interest.stage}}'] = eoi.interest.stage;
      tokenMap['{{interest.leadCategory}}'] = eoi.interest.leadCategory ?? '';
      tokenMap['{{interest.berthNumber}}'] = eoi.berth.mooringNumber;
      tokenMap['{{interest.dateFirstContact}}'] = eoi.interest.dateFirstContact
        ? eoi.interest.dateFirstContact.toLocaleDateString('en-GB')
        : '';
      tokenMap['{{interest.notes}}'] = eoi.interest.notes ?? '';
    } catch (err) {
      // buildEoiContext throws ValidationError when the interest has no yacht
      // or berth; non-EOI templates don't need those. Fall through to the
      // legacy resolution path below. Re-throw anything else.
      if (
        !(err instanceof ValidationError) ||
        !/interest has no (yacht|berth)/i.test(err.message)
      ) {
        throw err;
      }
    }
  }

  // ─── Legacy / non-EOI fallback ──────────────────────────────────────────────

  // Client tokens from direct client lookup (welcome letters, correspondence,
  // or EOI-flow clients where we still want client.source to resolve).
  if (context.clientId) {
    const client = await db.query.clients.findFirst({
      where: eq(clients.id, context.clientId),
    });
    if (client && client.portId === context.portId) {
      // Always resolve source from the DB — EoiContext doesn't carry it.
      if (tokenMap['{{client.source}}'] === undefined) {
        tokenMap['{{client.source}}'] = client.source ?? '';
      }

      // Only fill client.* tokens if the EOI path didn't already populate them.
      if (!eoiContextLoaded) {
        const contactList = await db.query.clientContacts.findMany({
          where: eq(clientContacts.clientId, context.clientId),
          orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)],
        });
        const emailContact = contactList.find((c) => c.channel === 'email');
        const phoneContact = contactList.find(
          (c) => c.channel === 'phone' || c.channel === 'whatsapp',
        );

        tokenMap['{{client.fullName}}'] = client.fullName ?? '';
        tokenMap['{{client.email}}'] = emailContact?.value ?? '';
        tokenMap['{{client.phone}}'] = phoneContact?.value ?? '';
        tokenMap['{{client.nationality}}'] = client.nationalityIso
          ? getCountryName(client.nationalityIso, 'en')
          : '';
      }
    }
  }

  // Interest tokens (legacy path — fills in fields EoiContext doesn't expose,
  // like eoiStatus / dateEoiSigned / dateContractSigned, or populates the
  // whole interest.* block when EOI resolution was skipped).
  if (context.interestId) {
    const interest = await db.query.interests.findFirst({
      where: eq(interests.id, context.interestId),
    });
    if (interest && interest.portId === context.portId) {
      if (!eoiContextLoaded) {
        tokenMap['{{interest.stage}}'] = interest.pipelineStage ?? '';
        tokenMap['{{interest.leadCategory}}'] = interest.leadCategory ?? '';
        tokenMap['{{interest.dateFirstContact}}'] = interest.dateFirstContact
          ? new Date(interest.dateFirstContact).toLocaleDateString('en-GB')
          : '';
        tokenMap['{{interest.notes}}'] = interest.notes ?? '';
      }
      // These are never populated by EoiContext — always fill them in.
      tokenMap['{{interest.eoiStatus}}'] = interest.eoiStatus ?? '';
      tokenMap['{{interest.dateEoiSigned}}'] = interest.dateEoiSigned
        ? new Date(interest.dateEoiSigned).toLocaleDateString('en-GB')
        : '';
      tokenMap['{{interest.dateContractSigned}}'] = interest.dateContractSigned
        ? new Date(interest.dateContractSigned).toLocaleDateString('en-GB')
        : '';
      // Derive berth number from the interest when berthId wasn't passed and
      // the EOI path didn't already populate it.
      if (!eoiContextLoaded && interest.berthId && !context.berthId) {
        const interestBerth = await db.query.berths.findFirst({
          where: eq(berths.id, interest.berthId),
        });
        if (interestBerth) {
          tokenMap['{{interest.berthNumber}}'] = interestBerth.mooringNumber;
          if (!tokenMap['{{berth.mooringNumber}}']) {
            tokenMap['{{berth.mooringNumber}}'] = interestBerth.mooringNumber;
          }
        } else {
          tokenMap['{{interest.berthNumber}}'] ??= '';
        }
      } else if (!eoiContextLoaded) {
        tokenMap['{{interest.berthNumber}}'] ??= context.berthId
          ? (tokenMap['{{berth.mooringNumber}}'] ?? '')
          : '';
      }
    }
  }

  // Berth tokens (legacy path — when a berthId is passed directly and EOI
  // resolution didn't already populate the berth block).
  if (context.berthId && !eoiContextLoaded) {
    const berth = await db.query.berths.findFirst({
      where: eq(berths.id, context.berthId),
    });
    if (berth && berth.portId === context.portId) {
      tokenMap['{{berth.mooringNumber}}'] = berth.mooringNumber;
      tokenMap['{{berth.area}}'] = berth.area ?? '';
      tokenMap['{{berth.status}}'] = berth.status;
      tokenMap['{{berth.price}}'] = berth.price ? String(berth.price) : '';
      tokenMap['{{berth.priceCurrency}}'] = berth.priceCurrency;
      tokenMap['{{berth.lengthFt}}'] = berth.lengthFt ? String(berth.lengthFt) : '';
      tokenMap['{{berth.widthFt}}'] = berth.widthFt ? String(berth.widthFt) : '';
      tokenMap['{{berth.tenureType}}'] = berth.tenureType;
      tokenMap['{{berth.tenureYears}}'] = berth.tenureYears ? String(berth.tenureYears) : '';
      tokenMap['{{interest.berthNumber}}'] = berth.mooringNumber;
    }
  }

  // BR-140: Check required merge fields have values
  const missing: string[] = [];
  for (const [, fields] of Object.entries(MERGE_FIELDS)) {
    for (const field of fields) {
      if (field.required) {
        const value = tokenMap[field.token];
        if (value !== undefined && value.trim() === '') {
          missing.push(field.label);
        }
      }
    }
  }

  if (missing.length > 0) {
    throw new ValidationError(`Missing required merge field values: ${missing.join(', ')}`);
  }

  // HTML body is required for the html template format; non-html formats
  // resolve elsewhere (see template_format dispatch in PR6).
  if (template.bodyHtml === null) {
    throw new ValidationError('Template has no HTML body to render');
  }

  // Interpolate all tokens
  let resolved: string = template.bodyHtml;
  for (const [token, value] of Object.entries(tokenMap)) {
    // Escape token for use in regex
    const escaped = token.replace(/[{}]/g, '\\$&');
    resolved = resolved.replace(new RegExp(escaped, 'g'), value);
  }

  return resolved;
}

// ─── Generate From Template ───────────────────────────────────────────────────

/**
 * BR-142: Resolve template → HTML → PDF. Store in MinIO + create file/document records.
 */
export async function generateFromTemplate(
  templateId: string,
  portId: string,
  context: GenerateInput,
  meta: AuditMeta,
): Promise<{ document: unknown; file: unknown }> {
  const template = await getTemplateById(templateId, portId);

  const resolvedHtml = await resolveTemplate(templateId, { ...context, portId });

  const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });

  // Wrap HTML in a minimal full-page document for pdfme text block
  const wrappedContent = resolvedHtml
    .replace(/<[^>]+>/g, ' ') // strip HTML tags for plain-text PDF rendering
    .replace(/\s+/g, ' ')
    .trim();

  // Use a simple single-field pdfme template for the HTML body
  const pdfTemplate = {
    basePdf: 'BLANK_PDF' as unknown as string,
    schemas: [
      [
        {
          name: 'portName',
          type: 'text' as const,
          position: { x: 20, y: 15 },
          width: 170,
          height: 10,
          fontSize: 14,
        },
        {
          name: 'body',
          type: 'text' as const,
          position: { x: 20, y: 30 },
          width: 170,
          height: 230,
          fontSize: 9,
        },
        {
          name: 'generatedAt',
          type: 'text' as const,
          position: { x: 20, y: 275 },
          width: 170,
          height: 6,
          fontSize: 7,
        },
      ],
    ],
  };

  const pdfBytes = await generatePdf(pdfTemplate, [
    {
      portName: `${port?.name ?? 'Port Nimara'} — ${template.name}`,
      body: wrappedContent,
      generatedAt: `Generated: ${new Date().toLocaleString('en-GB')}`,
    },
  ]);

  // Store in MinIO
  const fileId = crypto.randomUUID();
  const storagePath = buildStoragePath(
    port?.slug ?? portId,
    'document-templates',
    templateId,
    fileId,
    'pdf',
  );

  await minioClient.putObject(
    env.MINIO_BUCKET,
    storagePath,
    Buffer.from(pdfBytes),
    pdfBytes.byteLength,
    { 'Content-Type': 'application/pdf' },
  );

  // Create file record
  const [fileRecord] = await db
    .insert(files)
    .values({
      portId,
      clientId: context.clientId ?? null,
      filename: `${template.name.toLowerCase().replace(/\s+/g, '-')}.pdf`,
      originalName: `${template.name}.pdf`,
      mimeType: 'application/pdf',
      sizeBytes: String(pdfBytes.byteLength),
      storagePath,
      storageBucket: env.MINIO_BUCKET,
      category: 'correspondence',
      uploadedBy: meta.userId,
    })
    .returning();

  // Create document record
  const [documentRecord] = await db
    .insert(documents)
    .values({
      portId,
      clientId: context.clientId ?? null,
      interestId: context.interestId ?? null,
      documentType: template.templateType,
      title: template.name,
      status: 'draft',
      fileId: fileRecord!.id,
      isManualUpload: false,
      createdBy: meta.userId,
    })
    .returning();

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'create',
    entityType: 'document',
    entityId: documentRecord!.id,
    newValue: {
      templateId,
      templateName: template.name,
      clientId: context.clientId,
      interestId: context.interestId,
      berthId: context.berthId,
    },
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  emitToRoom(`port:${portId}`, 'document:created', { documentId: documentRecord!.id });

  return { document: documentRecord!, file: fileRecord! };
}

// ─── Generate and Send ────────────────────────────────────────────────────────

export async function generateAndSend(
  templateId: string,
  portId: string,
  context: GenerateInput,
  recipientEmail: string,
  meta: AuditMeta,
) {
  const { document, file } = await generateFromTemplate(templateId, portId, context, meta);
  const template = await getTemplateById(templateId, portId);
  const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });

  // Send email with PDF as attachment (base64 encoded body)
  try {
    const resolvedHtml = await resolveTemplate(templateId, { ...context, portId });
    await sendEmail(
      recipientEmail,
      template.name,
      `<p>Please find the attached document: <strong>${template.name}</strong></p><hr/>${resolvedHtml}`,
      `${port?.name ?? 'Port Nimara'} <noreply@${env.SMTP_HOST}>`,
    );
  } catch (err) {
    logger.error({ err, templateId, recipientEmail }, 'Failed to send template email');
    // Don't throw — document was created successfully; email failure is non-fatal
  }

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'create',
    entityType: 'documentTemplate',
    entityId: templateId,
    metadata: { action: 'generate_and_send', recipientEmail },
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  return { document, file };
}

// ─── EOI from source PDF (in-app pathway, EOI templates only) ─────────────────

/**
 * BR-142: For EOI templates, the in-app pathway uses the same source PDF as
 * the Documenso template — filled via pdf-lib with values from EoiContext.
 * Same field names, same legal document; the only difference is who renders
 * it. The form is left interactive so a recipient can adjust before signing.
 */
async function generateEoiFromSourcePdf(
  template: typeof documentTemplates.$inferSelect,
  portId: string,
  context: GenerateInput,
  meta: AuditMeta,
): Promise<{ document: DbDocument; file: DbFile }> {
  if (!context.interestId) {
    throw new ValidationError('interestId is required for EOI template generation');
  }

  const eoiContext = await buildEoiContext(context.interestId, portId);
  const pdfBytes = await generateEoiPdfFromTemplate(eoiContext);

  const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });

  const fileId = crypto.randomUUID();
  const storagePath = buildStoragePath(
    port?.slug ?? portId,
    'document-templates',
    template.id,
    fileId,
    'pdf',
  );

  await minioClient.putObject(
    env.MINIO_BUCKET,
    storagePath,
    Buffer.from(pdfBytes),
    pdfBytes.byteLength,
    { 'Content-Type': 'application/pdf' },
  );

  const [fileRecord] = await db
    .insert(files)
    .values({
      portId,
      clientId: context.clientId ?? null,
      filename: `${template.name.toLowerCase().replace(/\s+/g, '-')}.pdf`,
      originalName: `${template.name}.pdf`,
      mimeType: 'application/pdf',
      sizeBytes: String(pdfBytes.byteLength),
      storagePath,
      storageBucket: env.MINIO_BUCKET,
      category: 'eoi',
      uploadedBy: meta.userId,
    })
    .returning();

  const [documentRecord] = await db
    .insert(documents)
    .values({
      portId,
      clientId: context.clientId ?? null,
      interestId: context.interestId,
      documentType: template.templateType,
      title: template.name,
      status: 'draft',
      fileId: fileRecord!.id,
      isManualUpload: false,
      createdBy: meta.userId,
    })
    .returning();

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'create',
    entityType: 'document',
    entityId: documentRecord!.id,
    newValue: {
      templateId: template.id,
      templateName: template.name,
      source: 'eoi-source-pdf',
      clientId: context.clientId,
      interestId: context.interestId,
    },
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  emitToRoom(`port:${portId}`, 'document:created', { documentId: documentRecord!.id });

  return { document: documentRecord!, file: fileRecord! };
}

// ─── Generate and Sign ────────────────────────────────────────────────────────

/**
 * BR-142: EOI / NDA signing. Dual pathway:
 *   - `inapp`: produce the PDF locally (EOI templates fill the same source
 *     PDF as Documenso via pdf-lib; other template types fall back to the
 *     HTML→pdfme path), upload to MinIO, then upload to Documenso and send
 *     for signing.
 *   - `documenso-template`: skip our PDF generation entirely; call Documenso's
 *     template-generate endpoint with the shared EOI context. Documenso owns
 *     the PDF. We still record a `documents` row for tracking.
 */
export async function generateAndSign(
  templateId: string | null,
  portId: string,
  context: GenerateInput,
  signers: GenerateAndSignInput['signers'],
  pathway: 'inapp' | 'documenso-template',
  meta: AuditMeta,
) {
  if (pathway === 'documenso-template') {
    return generateAndSignViaDocumensoTemplate(portId, context, meta);
  }
  if (!templateId) {
    throw new ValidationError('templateId is required for inapp pathway');
  }
  return generateAndSignViaInApp(templateId, portId, context, signers, meta);
}

async function generateAndSignViaInApp(
  templateId: string,
  portId: string,
  context: GenerateInput,
  signers: GenerateAndSignInput['signers'],
  meta: AuditMeta,
) {
  const template = await getTemplateById(templateId, portId);

  // For EOI templates, signers default to the same set the Documenso template
  // pathway uses (interest's client + hardcoded developer + approver), so the
  // UI doesn't need to collect them. Non-EOI templates still require explicit
  // signers since they have no canonical recipient list.
  let resolvedSigners = signers;
  if ((!resolvedSigners || resolvedSigners.length === 0) && template.templateType === 'eoi') {
    if (!context.interestId) {
      throw new ValidationError(
        'interestId is required when generating an EOI without explicit signers',
      );
    }
    const eoiCtx = await buildEoiContext(context.interestId, portId);
    resolvedSigners = [
      {
        name: eoiCtx.client.fullName,
        email: eoiCtx.client.primaryEmail ?? '',
        role: 'signer',
        signingOrder: 1,
      },
      { name: 'David Mizrahi', email: 'dm@portnimara.com', role: 'signer', signingOrder: 2 },
      { name: 'Abbie May', email: 'sales@portnimara.com', role: 'approver', signingOrder: 3 },
    ];
  }
  if (!resolvedSigners || resolvedSigners.length === 0) {
    throw new ValidationError('signers are required for inapp pathway');
  }

  // EOI templates fill the same source PDF as the Documenso template (so both
  // pathways yield the same document). Other template types stay on the
  // HTML→pdfme rendering path.
  const { document: documentRecord, file } =
    template.templateType === 'eoi'
      ? await generateEoiFromSourcePdf(template, portId, context, meta)
      : ((await generateFromTemplate(templateId, portId, context, meta)) as {
          document: DbDocument;
          file: DbFile;
        });

  // Fetch PDF bytes from MinIO to send to Documenso
  const pdfStream = await minioClient.getObject(env.MINIO_BUCKET, file.storagePath);
  const chunks: Buffer[] = [];
  for await (const chunk of pdfStream) {
    chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as ArrayBuffer));
  }
  const pdfBase64 = Buffer.concat(chunks).toString('base64');

  // Create Documenso document
  const documensoDoc = await documensoCreate(
    template.name,
    pdfBase64,
    resolvedSigners.map((s) => ({
      name: s.name,
      email: s.email,
      role: s.role,
      signingOrder: s.signingOrder,
    })),
  );

  // Send document for signing
  await documensoSend(documensoDoc.id);

  // Update our document record with Documenso ID and status
  await db
    .update(documents)
    .set({
      documensoId: documensoDoc.id,
      status: 'sent',
      updatedAt: new Date(),
    })
    .where(eq(documents.id, documentRecord.id));

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'update',
    entityType: 'document',
    entityId: documentRecord.id,
    newValue: { status: 'sent', documensoId: documensoDoc.id },
    metadata: {
      action: 'generate_and_sign',
      pathway: 'inapp',
      signerCount: resolvedSigners.length,
    },
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  emitToRoom(`port:${portId}`, 'document:updated', {
    documentId: documentRecord.id,
    changedFields: ['status', 'documensoId'],
  });

  return { document: { ...documentRecord, documensoId: documensoDoc.id, status: 'sent' }, file };
}

async function generateAndSignViaDocumensoTemplate(
  portId: string,
  context: GenerateInput,
  meta: AuditMeta,
) {
  if (!context.interestId) {
    throw new ValidationError('interestId is required for documenso-template pathway');
  }

  const eoiContext = await buildEoiContext(context.interestId, portId);

  const payload = buildDocumensoPayload(eoiContext, {
    interestId: context.interestId,
    clientRecipientId: env.DOCUMENSO_CLIENT_RECIPIENT_ID,
    developerRecipientId: env.DOCUMENSO_DEVELOPER_RECIPIENT_ID,
    approvalRecipientId: env.DOCUMENSO_APPROVAL_RECIPIENT_ID,
    redirectUrl: env.APP_URL,
  });

  const documensoDoc = await documensoGenerateFromTemplate(
    env.DOCUMENSO_TEMPLATE_ID_EOI,
    payload as unknown as Record<string, unknown>,
  );

  // Record a documents row referencing the Documenso document. No local file —
  // Documenso owns the PDF and delivers signed copies via webhook (handled elsewhere).
  const [documentRecord] = await db
    .insert(documents)
    .values({
      portId,
      clientId: context.clientId ?? null,
      interestId: context.interestId,
      documentType: 'eoi',
      title: payload.title,
      status: 'sent',
      documensoId: documensoDoc.id,
      isManualUpload: false,
      createdBy: meta.userId,
    })
    .returning();

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'create',
    entityType: 'document',
    entityId: documentRecord!.id,
    newValue: { documensoId: documensoDoc.id, status: 'sent' },
    metadata: {
      action: 'generate_and_sign',
      pathway: 'documenso-template',
      templateId: env.DOCUMENSO_TEMPLATE_ID_EOI,
      interestId: context.interestId,
    },
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  emitToRoom(`port:${portId}`, 'document:created', { documentId: documentRecord!.id });

  return { document: documentRecord!, file: null };
}
