/**
 * External EOI upload - for EOIs signed outside Documenso (paper signing,
 * different e-sign vendor, signed in person, etc).
 *
 * Creates BOTH the document row AND the signed-file record in one shot,
 * then advances the interest stage. Distinct from the existing
 * uploadSignedManually flow which augments a document row that was
 * already created via the Documenso pathway.
 */

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

import { db } from '@/lib/db';
import { interests } from '@/lib/db/schema/interests';
import { documents, documentEvents, documentSigners, files } from '@/lib/db/schema/documents';
import { ports } from '@/lib/db/schema/ports';
import { env } from '@/lib/env';
import { buildStoragePath } from '@/lib/minio';
import { getStorageBackend } from '@/lib/storage';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { canonicalizeStage, type PipelineStage } from '@/lib/constants';
import { CodedError, ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
import type { ExternalSignedDocType } from '@/lib/services/external-signing.service';

/**
 * Per-doc-type config for an externally-signed *upload* (file present).
 * Mirrors external-signing.service's no-file status/rule maps and adds the
 * storage / document-type / stage-advance details the file path needs, so the
 * one upload service correctly files an EOI, a reservation agreement, OR a
 * contract instead of always filing as an EOI.
 */
const UPLOAD_CONFIG: Record<
  ExternalSignedDocType,
  {
    documentType: 'eoi' | 'reservation_agreement' | 'contract';
    fileCategory: 'eoi' | 'contract';
    storagePrefix: 'eoi-signed' | 'reservation-signed' | 'contract-signed';
    docStatusCol: 'eoiDocStatus' | 'reservationDocStatus' | 'contractDocStatus';
    dateCol: 'dateEoiSigned' | 'dateReservationSigned' | 'dateContractSigned';
    targetStage: PipelineStage;
    advanceFrom: PipelineStage[];
    rule: 'eoi_signed' | 'contract_signed' | null;
    label: string;
  }
> = {
  eoi: {
    documentType: 'eoi',
    fileCategory: 'eoi',
    storagePrefix: 'eoi-signed',
    docStatusCol: 'eoiDocStatus',
    dateCol: 'dateEoiSigned',
    targetStage: 'eoi',
    advanceFrom: ['enquiry', 'qualified', 'nurturing'],
    rule: 'eoi_signed',
    label: 'EOI',
  },
  reservation: {
    documentType: 'reservation_agreement',
    fileCategory: 'contract',
    storagePrefix: 'reservation-signed',
    docStatusCol: 'reservationDocStatus',
    dateCol: 'dateReservationSigned',
    targetStage: 'reservation',
    advanceFrom: ['enquiry', 'qualified', 'nurturing', 'eoi'],
    rule: null,
    label: 'reservation agreement',
  },
  contract: {
    documentType: 'contract',
    fileCategory: 'contract',
    storagePrefix: 'contract-signed',
    docStatusCol: 'contractDocStatus',
    dateCol: 'dateContractSigned',
    targetStage: 'contract',
    advanceFrom: ['enquiry', 'qualified', 'nurturing', 'eoi', 'reservation', 'deposit_paid'],
    rule: 'contract_signed',
    label: 'contract',
  },
};

/** A single signatory on an externally-signed document. */
export interface ExternalSignatory {
  name: string;
  email: string;
  /** Drives auto-fill on the dialog + downstream "Email copy" recipient
   *  list. `cc` recipients aren't signers but show up on send-out lists. */
  role: 'client' | 'developer' | 'rep' | 'witness' | 'cc';
}

export interface ExternalEoiInput {
  interestId: string;
  portId: string;
  /** PDF bytes. */
  fileData: { buffer: Buffer; originalName: string; mimeType: string; size: number };
  /** Free-text title for the document row. Defaults to "External EOI - <date>". */
  title?: string;
  /** When the signing actually happened (the date on the paper / contract). */
  signedAt?: Date;
  /**
   * Structured signatory list - preferred over the legacy `signerNames`
   * CSV. When present, the service inserts one `document_signers` row
   * per non-CC entry pre-stamped `status='signed'` so the
   * "X / Y signed" badge renders correctly downstream.
   */
  signatories?: ExternalSignatory[];
  /** Legacy CSV form kept for backwards compat with older callers. */
  signerNames?: string[];
  /** Free-text note (e.g. "signed in person at boat show"). */
  notes?: string;
  /**
   * Optional: id of an active generated EOI to cancel as part of this
   * upload. When set, the service voids the Documenso envelope and flips
   * the prior local row to `status='cancelled'` BEFORE creating the new
   * external-EOI doc so a deal carries one canonical EOI at any moment.
   * Idempotent — already-cancelled / wrong-port ids are ignored.
   */
  cancelActiveDocumentId?: string;
  /** Which signed document this upload represents. Defaults to 'eoi' for
   *  backward compatibility; reservation/contract tabs pass their own type so
   *  the file is filed + the interest advanced under the correct kind. */
  docType?: ExternalSignedDocType;
  meta: AuditMeta;
}

export async function uploadExternallySignedEoi(input: ExternalEoiInput) {
  const { interestId, portId, fileData, meta } = input;
  const docType: ExternalSignedDocType = input.docType ?? 'eoi';
  const cfg = UPLOAD_CONFIG[docType];

  if (fileData.size <= 0) throw new ValidationError('Empty file');
  if (fileData.size > 25 * 1024 * 1024) {
    throw new ValidationError('File too large (max 25 MB)');
  }
  if (
    fileData.mimeType !== 'application/pdf' &&
    !fileData.originalName.toLowerCase().endsWith('.pdf')
  ) {
    throw new ValidationError(`Only PDF uploads are accepted for signed ${cfg.label}s`);
  }

  const interest = await db.query.interests.findFirst({
    where: eq(interests.id, interestId),
  });
  if (!interest || interest.portId !== portId) throw new NotFoundError('Interest');

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

  // Replace-mode: cancel the prior active EOI before creating the new one.
  // Done OUTSIDE the upload transaction so a Documenso void error doesn't
  // block the upload the rep has already photographed/scanned. cancelDocument
  // is idempotent on status='cancelled' so concurrent calls are safe.
  let cancelledDocumentId: string | null = null;
  if (input.cancelActiveDocumentId) {
    try {
      const { cancelDocument } = await import('@/lib/services/documents.service');
      await cancelDocument(input.cancelActiveDocumentId, portId, meta, {
        reason: 'Replaced by external upload',
        cancelMode: 'delete',
      });
      cancelledDocumentId = input.cancelActiveDocumentId;
    } catch {
      // Swallow — the rep meant to replace, but failing to cancel the prior
      // doc shouldn't lose the upload. The audit log on the new doc still
      // captures the rep's intent via metadata.replacedDocumentId.
    }
  }

  const documentId = crypto.randomUUID();
  const fileId = crypto.randomUUID();
  const storagePath = buildStoragePath(port.slug, cfg.storagePrefix, documentId, fileId, 'pdf');

  // Upload to storage FIRST so we have a stable key for the DB rows,
  // then commit all four DB writes in one transaction. If the tx fails
  // the storage object becomes orphaned (S3 isn't transactional) but
  // the DB stays clean - orphan reaper handles those.
  await (
    await getStorageBackend()
  ).put(storagePath, fileData.buffer, {
    contentType: 'application/pdf',
    sizeBytes: fileData.size,
  });

  const title =
    input.title ??
    `External ${cfg.label} - ${(input.signedAt ?? new Date()).toISOString().slice(0, 10)}`;

  const result = await db.transaction(async (tx) => {
    const [fileRecord] = await tx
      .insert(files)
      .values({
        portId,
        clientId: interest.clientId,
        filename: fileData.originalName,
        originalName: fileData.originalName,
        mimeType: 'application/pdf',
        sizeBytes: String(fileData.size),
        storagePath,
        storageBucket: env.MINIO_BUCKET,
        category: cfg.fileCategory,
        uploadedBy: meta.userId,
      })
      .returning();
    if (!fileRecord) {
      throw new CodedError('INSERT_RETURNING_EMPTY', {
        internalMessage: 'External EOI file insert returned no row',
      });
    }

    const [doc] = await tx
      .insert(documents)
      .values({
        id: documentId,
        portId,
        interestId,
        clientId: interest.clientId,
        yachtId: interest.yachtId,
        documentType: cfg.documentType,
        title,
        status: 'completed',
        isManualUpload: true,
        signedFileId: fileRecord.id,
        notes: input.notes ?? null,
        createdBy: meta.userId,
      })
      .returning();
    if (!doc) {
      throw new CodedError('INSERT_RETURNING_EMPTY', {
        internalMessage: 'External EOI document insert returned no row',
      });
    }

    // Backfill document_signers rows for the structured signatory list
    // so the document-detail "X / Y signed" badge counts correctly. CC
    // recipients aren't signatories - they're recipients of the email
    // copy and don't show up in the signed-count denominator.
    const signedAtMoment = input.signedAt ?? new Date();
    const signerRows = (input.signatories ?? [])
      .filter((s) => s.role !== 'cc')
      .map((s, i) => ({
        documentId: doc.id,
        signerName: s.name,
        signerEmail: s.email,
        signerRole: s.role,
        signingOrder: i + 1,
        status: 'signed' as const,
        signedAt: signedAtMoment,
      }));
    if (signerRows.length > 0) {
      await tx.insert(documentSigners).values(signerRows);
    }

    await tx.insert(documentEvents).values({
      documentId: doc.id,
      eventType: 'completed',
      eventData: {
        isManualUpload: true,
        external: true,
        signerNames: input.signerNames ?? (input.signatories ?? []).map((s) => s.name),
        signatories: input.signatories ?? null,
        signedAt: signedAtMoment.toISOString(),
        fileId: fileRecord.id,
      },
    });

    // Two concerns to keep separate:
    //   1. Document metadata - always write dateEoiSigned, eoiStatus, and
    //      eoiDocStatus from the upload. Even if the rep already advanced
    //      the stage manually, the paper signing event needs a recorded
    //      date + doc-status badge so downstream surfaces (SkipAheadBanner,
    //      milestone strip, EOI merge fields, signing-progress chip) reflect
    //      reality. Honour an existing dateEoiSigned - covers the case where
    //      the rep is uploading evidence for an event whose date was already
    //      backfilled.
    //   2. Stage advance - in the 7-stage pipeline (PIPELINE_STAGES), every
    //      pre-EOI stage (enquiry / qualified / nurturing) should flip to
    //      'eoi' on signing. The doc-status 'signed' carries the within-stage
    //      sub-state. Bypasses canTransitionStage because the operator just
    //      brought concrete proof. Idempotent at the 'eoi' / 'reservation' /
    //      'deposit_paid' / 'contract' stages (stays put).
    const stageBeforeAdvance = interest.pipelineStage as PipelineStage | null | undefined;
    const canonicalStage = canonicalizeStage(stageBeforeAdvance);
    const shouldAdvanceStage = cfg.advanceFrom.includes(canonicalStage);

    const existingDate = (interest[cfg.dateCol] as Date | null) ?? null;
    const interestPatch = {
      [cfg.dateCol]: existingDate ?? input.signedAt ?? new Date(),
      [cfg.docStatusCol]: 'signed',
      // EOI carries a dedicated `eoi_status` lifecycle column; reservation /
      // contract have only the `*_doc_status` sub-state (mirrors markExternallySigned).
      ...(docType === 'eoi' ? { eoiStatus: 'signed' as const } : {}),
      ...(shouldAdvanceStage ? { pipelineStage: cfg.targetStage } : {}),
      updatedAt: new Date(),
    } as Partial<typeof interests.$inferInsert>;

    await tx.update(interests).set(interestPatch).where(eq(interests.id, interestId));

    return {
      documentId: doc.id,
      fileId: fileRecord.id,
      stageChanged: shouldAdvanceStage,
      newStage: shouldAdvanceStage ? cfg.targetStage : canonicalStage,
    };
  });

  const { documentId: docId, fileId: fId, stageChanged, newStage } = result;

  void createAuditLog({
    portId,
    userId: meta.userId,
    action: 'create',
    entityType: 'document',
    entityId: docId,
    metadata: {
      kind: `external_${docType}_upload`,
      docType,
      interestId,
      title,
      signerNames: input.signerNames ?? [],
      signedAt: (input.signedAt ?? new Date()).toISOString(),
      fileSizeBytes: fileData.size,
      ...(cancelledDocumentId ? { replacedDocumentId: cancelledDocumentId } : {}),
    },
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  emitToRoom(`port:${portId}`, 'document:completed', { documentId: docId });

  // Berth rules engine: a manually-uploaded external EOI is still an
  // EOI-signed event for the rules that watch this trigger (e.g.
  // auto-mark the primary berth Under Offer). Fire via dynamic import
  // to dodge the circular dep between berth-rules-engine and the
  // interest services.
  try {
    if (cfg.rule) {
      const { evaluateRule } = await import('@/lib/services/berth-rules-engine');
      await evaluateRule(cfg.rule, interestId, portId, meta);
    }
  } catch {
    // Swallow - rules engine failures should never block the upload
    // that the rep has already completed end-to-end. The orphan-reaper
    // path doesn't apply; a missed rule evaluation is a soft failure.
  }

  return { documentId: docId, fileId: fId, stageChanged, newStage, cancelledDocumentId };
}

// ─── Edit metadata on a previously-uploaded external EOI ─────────────────────

/** Signatory row in an edit payload. `id` distinguishes update vs insert. */
export interface ExternalEoiSignatoryEdit {
  /** Present on existing rows; omitted to insert a new signer. */
  id?: string;
  name: string;
  email: string;
  role: 'client' | 'developer' | 'rep' | 'witness' | 'cc';
}

export interface ExternalEoiMetadataPatch {
  documentId: string;
  portId: string;
  /** Undefined = leave unchanged; null is not allowed (title is NOT NULL). */
  title?: string;
  /** Undefined = leave unchanged; null clears `interests.dateEoiSigned`. */
  signedAt?: Date | null;
  /** Undefined = leave unchanged; '' clears the column. */
  notes?: string;
  /**
   * Full replacement set when present. Rows with an id are UPDATEd; rows
   * without are INSERTed; existing rows whose id isn't in the array are
   * DELETEd. CC entries are stored but excluded from the X/Y signed
   * count (status stays signed=signedAt to match the upload-time
   * pre-stamp).
   */
  signatories?: ExternalEoiSignatoryEdit[];
  meta: AuditMeta;
}

/**
 * Update title / notes / signed-date / signatories on a previously-uploaded
 * external EOI. Refuses to touch Documenso-managed documents because their
 * signer rows are the vendor's source of truth - any edit would drift from
 * the upstream envelope.
 *
 * Mirrors the upload service's invariants:
 *   - `signedAt` updates BOTH `documents` events and `interests.dateEoiSigned`
 *     (and the per-signer `signedAt` stamp for the structured signatories).
 *   - Document_signers writes are full-replacement when `signatories` is
 *     present (insert / update / delete by id-presence). Same shape as the
 *     upload-time insert: CC entries persisted but not counted as signers.
 *
 * Stage advance is NOT re-evaluated - that fires once at upload and shouldn't
 * be reversed by a metadata edit. If the rep needs to roll a stage back,
 * they do it through the stage-change UI directly.
 */
export async function updateExternalEoiMetadata(input: ExternalEoiMetadataPatch) {
  const { documentId, portId, meta } = input;

  const document = await db.query.documents.findFirst({
    where: and(eq(documents.id, documentId), eq(documents.portId, portId)),
  });
  if (!document) throw new NotFoundError('Document');
  if (!document.isManualUpload) {
    throw new ConflictError(
      'Only manually-uploaded documents can have their metadata edited. Documenso-managed documents inherit their signers and signing date from the upstream envelope.',
    );
  }
  if (document.documentType !== 'eoi') {
    // The form only knows EOI shape today; widen later when other doc
    // types grow their own external-upload pathways.
    throw new ConflictError(
      'Metadata edit is currently supported only for EOI documents. Open a ticket if you need it for contracts or reservations.',
    );
  }

  // Capture before-state for the audit log.
  const beforeSigners = await db.query.documentSigners.findMany({
    where: eq(documentSigners.documentId, documentId),
  });

  const result = await db.transaction(async (tx) => {
    // 1) Patch the document row itself.
    const docPatch: Partial<typeof documents.$inferInsert> = { updatedAt: new Date() };
    if (input.title !== undefined) docPatch.title = input.title;
    if (input.notes !== undefined) docPatch.notes = input.notes === '' ? null : input.notes;
    await tx.update(documents).set(docPatch).where(eq(documents.id, documentId));

    // 2) Sync the interest's `dateEoiSigned` when signedAt is being
    //    edited. Honour the upload-side rule: a metadata edit IS the
    //    canonical source for the date, so we overwrite even when the
    //    column already has a value (the rep is presumably fixing it).
    if (input.signedAt !== undefined && document.interestId) {
      await tx
        .update(interests)
        .set({
          dateEoiSigned: input.signedAt,
          updatedAt: new Date(),
        })
        .where(eq(interests.id, document.interestId));
    }

    // 3) Replace the signatories list when provided. Mirror the upload
    //    invariants: status='signed' on every non-CC row (the doc has
    //    already been signed externally), signedAt stamped from the
    //    edit's effective signing date.
    if (input.signatories !== undefined) {
      const signedAtMoment =
        input.signedAt !== undefined
          ? (input.signedAt ?? new Date())
          : (beforeSigners[0]?.signedAt ?? new Date());

      const submittedIds = new Set(input.signatories.filter((s) => s.id).map((s) => s.id!));
      const existingIds = beforeSigners.map((s) => s.id);
      const toDelete = existingIds.filter((id) => !submittedIds.has(id));

      if (toDelete.length > 0) {
        await tx.delete(documentSigners).where(inArray(documentSigners.id, toDelete));
      }

      for (let i = 0; i < input.signatories.length; i++) {
        const s = input.signatories[i]!;
        const isSigner = s.role !== 'cc';
        if (s.id && existingIds.includes(s.id)) {
          await tx
            .update(documentSigners)
            .set({
              signerName: s.name,
              signerEmail: s.email,
              signerRole: s.role,
              signingOrder: i + 1,
              status: isSigner ? 'signed' : 'pending',
              signedAt: isSigner ? signedAtMoment : null,
            })
            .where(eq(documentSigners.id, s.id));
        } else {
          await tx.insert(documentSigners).values({
            documentId,
            signerName: s.name,
            signerEmail: s.email,
            signerRole: s.role,
            signingOrder: i + 1,
            status: isSigner ? 'signed' : 'pending',
            signedAt: isSigner ? signedAtMoment : null,
          });
        }
      }
    }

    // 4) Trail in document_events so the activity timeline reflects the
    //    edit alongside the original 'completed' row.
    await tx.insert(documentEvents).values({
      documentId,
      eventType: 'metadata_updated',
      eventData: {
        editedBy: meta.userId,
        fields: {
          title: input.title !== undefined,
          signedAt: input.signedAt !== undefined,
          notes: input.notes !== undefined,
          signatories: input.signatories !== undefined,
        },
      },
    });

    return { documentId };
  });

  void createAuditLog({
    portId,
    userId: meta.userId,
    action: 'update',
    entityType: 'document',
    entityId: documentId,
    metadata: {
      kind: 'external_eoi_metadata_edit',
      fieldsChanged: {
        title: input.title !== undefined,
        signedAt: input.signedAt !== undefined,
        notes: input.notes !== undefined,
        signatories: input.signatories !== undefined,
      },
    },
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  emitToRoom(`port:${portId}`, 'document:updated', { documentId: result.documentId });
  return { documentId: result.documentId };
}
