import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { DOCUMENT_AUDIT_LOG_TYPE, RECIPIENT_DIFF_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import {
  DocumentSigningOrder,
  DocumentStatus,
  EnvelopeType,
  FieldType,
  RecipientRole,
  SendStatus,
  SigningStatus,
  WebhookTriggerEvents,
} from '@prisma/client';
import { DateTime } from 'luxon';

import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import type { TRecipientAccessAuth } from '../../types/document-auth';
import { DocumentAuth } from '../../types/document-auth';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapSecondaryIdToDocumentId, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { assertRecipientNotExpired } from '../../utils/recipients';
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { isRecipientAuthorized } from './is-recipient-authorized';

export type CompleteDocumentWithTokenOptions = {
  token: string;
  id: EnvelopeIdOptions;
  userId?: number;
  accessAuthOptions?: TRecipientAccessAuth;
  requestMetadata?: RequestMetadata;
  nextSigner?: {
    email: string;
    name: string;
  };
  /**
   * Override the recipient information. This will only work if the recipient
   * does not have a name or email set.
   */
  recipientOverride?: {
    email?: string;
    name?: string;
  };
};

export const completeDocumentWithToken = async ({
  token,
  id,
  userId,
  accessAuthOptions,
  requestMetadata,
  nextSigner,
  recipientOverride,
}: CompleteDocumentWithTokenOptions) => {
  const envelope = await prisma.envelope.findFirst({
    where: {
      ...unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
      recipients: {
        some: {
          token,
        },
      },
    },
    include: {
      documentMeta: true,
      recipients: {
        where: {
          token,
        },
      },
    },
  });

  // The most common cause is a stale signing page: the document was deleted,
  // or the recipient was removed, after the link was opened. Surface a
  // NOT_FOUND instead of leaking a Prisma P2025 as a 500.
  if (!envelope) {
    throw new AppError(AppErrorCode.NOT_FOUND, {
      message: 'Document not found for the provided signing token',
      statusCode: 404,
    });
  }

  const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);

  if (envelope.recipients.length === 0) {
    throw new AppError(AppErrorCode.NOT_FOUND, {
      message: `Document ${envelope.id} has no recipient with the provided token`,
      statusCode: 404,
    });
  }

  const [recipient] = envelope.recipients;

  // A retried or duplicate completion request for an already signed
  // recipient throws a code the router resolves idempotently. This must be
  // checked before the envelope status guard since the envelope may have
  // been completed and sealed by the recipient's original request.
  if (recipient.signingStatus === SigningStatus.SIGNED) {
    throw new AppError(AppErrorCode.RECIPIENT_ALREADY_SIGNED, {
      message: `Recipient ${recipient.id} has already signed`,
      statusCode: 400,
    });
  }

  if (envelope.status !== DocumentStatus.PENDING) {
    const envelopeStatusErrorCode: Record<DocumentStatus, AppErrorCode> = {
      [DocumentStatus.DRAFT]: AppErrorCode.ENVELOPE_DRAFT,
      [DocumentStatus.COMPLETED]: AppErrorCode.ENVELOPE_COMPLETED,
      [DocumentStatus.REJECTED]: AppErrorCode.ENVELOPE_REJECTED,
      [DocumentStatus.CANCELLED]: AppErrorCode.ENVELOPE_CANCELLED,
      // Unreachable: guarded by the status check above.
      [DocumentStatus.PENDING]: AppErrorCode.INVALID_REQUEST,
    };

    throw new AppError(envelopeStatusErrorCode[envelope.status], {
      message: `Document ${envelope.id} must be pending to be completed, found ${envelope.status}`,
      statusCode: 400,
    });
  }

  assertRecipientNotExpired(recipient);

  if (recipient.signingStatus === SigningStatus.REJECTED) {
    throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
      message: 'Recipient has already rejected the document',
      statusCode: 400,
    });
  }

  if (envelope.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
    const isRecipientsTurn = await getIsRecipientsTurnToSign({
      token: recipient.token,
    });

    if (!isRecipientsTurn) {
      throw new AppError(AppErrorCode.RECIPIENT_OUT_OF_TURN, {
        message: `Recipient ${recipient.id} attempted to complete the document before it was their turn`,
        statusCode: 400,
      });
    }
  }

  // Check ACCESS AUTH 2FA validation during document completion
  const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
    documentAuth: envelope.authOptions,
    recipientAuth: recipient.authOptions,
  });

  if (derivedRecipientAccessAuth.includes(DocumentAuth.TWO_FACTOR_AUTH)) {
    if (!accessAuthOptions) {
      throw new AppError(AppErrorCode.UNAUTHORIZED, {
        message: 'Access authentication required',
      });
    }

    if (!recipient.email.trim()) {
      throw new AppError(AppErrorCode.INVALID_REQUEST, {
        message: `Recipient ${recipient.id} requires an email because they have auth requirements.`,
      });
    }

    const isValid = await isRecipientAuthorized({
      type: 'ACCESS_2FA',
      documentAuthOptions: envelope.authOptions,
      recipient: recipient,
      userId, // Can be undefined for non-account recipients
      authOptions: accessAuthOptions,
    });

    if (!isValid) {
      await prisma.documentAuditLog.create({
        data: createDocumentAuditLogData({
          type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_FAILED,
          envelopeId: envelope.id,
          data: {
            recipientId: recipient.id,
            recipientName: recipient.name,
            recipientEmail: recipient.email,
          },
        }),
      });

      throw new AppError(AppErrorCode.TWO_FACTOR_AUTH_FAILED, {
        message: 'Invalid 2FA authentication',
      });
    }

    await prisma.documentAuditLog.create({
      data: createDocumentAuditLogData({
        type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_VALIDATED,
        envelopeId: envelope.id,
        data: {
          recipientId: recipient.id,
          recipientName: recipient.name,
          recipientEmail: recipient.email,
        },
      }),
    });
  }

  let fields = await prisma.field.findMany({
    where: {
      envelopeId: envelope.id,
      recipientId: recipient.id,
    },
  });

  // This should be scoped to the current recipient.
  const uninsertedDateFields = fields.filter((field) => field.type === FieldType.DATE && !field.inserted);

  let recipientName = recipient.name;
  let recipientEmail = recipient.email;

  // Only trim the name if it's been derived.
  if (!recipientName) {
    recipientName = (
      recipientOverride?.name ||
      fields.find((field) => field.type === FieldType.NAME)?.customText ||
      ''
    ).trim();
  }

  // Only trim the email if it's been derived.
  if (!recipient.email) {
    recipientEmail = (
      recipientOverride?.email ||
      fields.find((field) => field.type === FieldType.EMAIL)?.customText ||
      ''
    )
      .trim()
      .toLowerCase();
  }

  if (!recipientEmail) {
    throw new AppError(AppErrorCode.INVALID_BODY, {
      message: 'Recipient email is required',
    });
  }

  // Auto-insert all un-inserted date fields for V2 envelopes at completion time.
  if (envelope.internalVersion === 2 && uninsertedDateFields.length > 0) {
    const formattedDate = DateTime.now()
      .setZone(envelope.documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE)
      .toFormat(envelope.documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT);

    const newDateFieldValues = {
      customText: formattedDate,
      inserted: true,
    };

    await prisma.field.updateMany({
      where: {
        id: {
          in: uninsertedDateFields.map((field) => field.id),
        },
      },
      data: {
        ...newDateFieldValues,
      },
    });

    // Create audit log entries for each auto-inserted date field.
    await prisma.documentAuditLog.createMany({
      data: uninsertedDateFields.map((field) =>
        createDocumentAuditLogData({
          type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
          envelopeId: envelope.id,
          user: {
            email: recipientEmail,
            name: recipientName,
          },
          requestMetadata,
          data: {
            recipientEmail: recipientEmail,
            recipientId: recipient.id,
            recipientName: recipientName,
            recipientRole: recipient.role,
            fieldId: field.secondaryId,
            field: {
              type: FieldType.DATE,
              data: formattedDate,
            },
          },
        }),
      ),
    });

    // Update the local fields array so the subsequent validation check passes.
    fields = fields.map((field) => {
      if (field.type === FieldType.DATE && !field.inserted) {
        return {
          ...field,
          ...newDateFieldValues,
        };
      }

      return field;
    });
  }

  if (fieldsContainUnsignedRequiredField(fields)) {
    throw new AppError(AppErrorCode.RECIPIENT_HAS_UNSIGNED_FIELDS, {
      message: `Recipient ${recipient.id} has unsigned fields`,
      statusCode: 400,
    });
  }

  await prisma.$transaction(async (tx) => {
    // Conditional update so two concurrent completion requests can't both
    // proceed: only the request that transitions the recipient to SIGNED
    // continues, the loser sees a count of 0 and aborts.
    const { count: updatedRecipientCount } = await tx.recipient.updateMany({
      where: {
        id: recipient.id,
        signingStatus: {
          not: SigningStatus.SIGNED,
        },
      },
      data: {
        signingStatus: SigningStatus.SIGNED,
        signedAt: new Date(),
        name: recipientName,
        email: recipientEmail,
      },
    });

    // A concurrent request completed the recipient between our initial read
    // and this transaction. Abort so the winning request handles all side
    // effects, the router resolves this code idempotently.
    if (updatedRecipientCount === 0) {
      throw new AppError(AppErrorCode.RECIPIENT_ALREADY_SIGNED, {
        message: `Recipient ${recipient.id} has already signed`,
        statusCode: 400,
      });
    }

    if (recipientEmail !== recipient.email || recipientName !== recipient.name) {
      await tx.documentAuditLog.create({
        data: createDocumentAuditLogData({
          type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
          envelopeId: envelope.id,
          user: {
            name: recipientName,
            email: recipientEmail,
          },
          requestMetadata,
          data: {
            recipientEmail: recipient.email,
            recipientName: recipient.name,
            recipientId: recipient.id,
            recipientRole: recipient.role,
            changes: [
              {
                type: RECIPIENT_DIFF_TYPE.NAME,
                from: recipient.name,
                to: recipientName,
              },
              {
                type: RECIPIENT_DIFF_TYPE.EMAIL,
                from: recipient.email,
                to: recipientEmail,
              },
            ],
          },
        }),
      });
    }

    const authOptions = extractDocumentAuthMethods({
      documentAuth: envelope.authOptions,
      recipientAuth: recipient.authOptions,
    });

    await tx.documentAuditLog.create({
      data: createDocumentAuditLogData({
        type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
        envelopeId: envelope.id,
        user: {
          name: recipientName,
          email: recipientEmail,
        },
        requestMetadata,
        data: {
          recipientEmail: recipientEmail,
          recipientName: recipientName,
          recipientId: recipient.id,
          recipientRole: recipient.role,
          actionAuth: authOptions.derivedRecipientActionAuth,
        },
      }),
    });
  });

  const envelopeWithRelations = await prisma.envelope.findUniqueOrThrow({
    where: { id: envelope.id },
    include: { documentMeta: true, recipients: true },
  });

  await triggerWebhook({
    event: WebhookTriggerEvents.DOCUMENT_RECIPIENT_COMPLETED,
    data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelopeWithRelations)),
    userId: envelope.userId,
    teamId: envelope.teamId,
  });

  await jobs.triggerJob({
    name: 'send.recipient.signed.email',
    payload: {
      documentId: legacyDocumentId,
      recipientId: recipient.id,
    },
  });

  const pendingRecipients = await prisma.recipient.findMany({
    select: {
      id: true,
      signingOrder: true,
      name: true,
      email: true,
      role: true,
    },
    where: {
      envelopeId: envelope.id,
      signingStatus: {
        not: SigningStatus.SIGNED,
      },
      role: {
        not: RecipientRole.CC,
      },
    },
    // Composite sort so our next recipient is always the one with the lowest signing order or id
    // if there is a tie.
    orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
  });

  if (pendingRecipients.length > 0) {
    await jobs.triggerJob({
      name: 'send.document.pending.email',
      payload: {
        envelopeId: envelope.id,
        recipientId: recipient.id,
      },
    });

    if (envelope.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
      const [nextRecipient] = pendingRecipients;

      await prisma.$transaction(async (tx) => {
        if (nextSigner && envelope.documentMeta?.allowDictateNextSigner) {
          await tx.documentAuditLog.create({
            data: createDocumentAuditLogData({
              type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
              envelopeId: envelope.id,
              user: {
                name: recipientName,
                email: recipientEmail,
              },
              requestMetadata,
              data: {
                recipientEmail: nextRecipient.email,
                recipientName: nextRecipient.name,
                recipientId: nextRecipient.id,
                recipientRole: nextRecipient.role,
                changes: [
                  {
                    type: RECIPIENT_DIFF_TYPE.NAME,
                    from: nextRecipient.name,
                    to: nextSigner.name,
                  },
                  {
                    type: RECIPIENT_DIFF_TYPE.EMAIL,
                    from: nextRecipient.email,
                    to: nextSigner.email,
                  },
                ],
              },
            }),
          });
        }

        await tx.recipient.update({
          where: { id: nextRecipient.id },
          data: {
            sendStatus: SendStatus.SENT,
            sentAt: new Date(),
            ...(nextSigner && envelope.documentMeta?.allowDictateNextSigner
              ? {
                  name: nextSigner.name,
                  email: nextSigner.email,
                }
              : {}),
          },
        });
      });

      await jobs.triggerJob({
        name: 'send.signing.requested.email',
        payload: {
          userId: envelope.userId,
          documentId: legacyDocumentId,
          recipientId: nextRecipient.id,
          requestMetadata,
        },
      });
    }
  }

  const haveAllRecipientsSigned = await prisma.envelope.findFirst({
    where: {
      id: envelope.id,
      recipients: {
        every: {
          OR: [{ signingStatus: SigningStatus.SIGNED }, { role: RecipientRole.CC }],
        },
      },
    },
  });

  if (haveAllRecipientsSigned) {
    await jobs.triggerJob({
      name: 'internal.seal-document',
      payload: {
        documentId: legacyDocumentId,
        requestMetadata,
      },
    });
  }

  const updatedDocument = await prisma.envelope.findFirstOrThrow({
    where: {
      id: envelope.id,
      type: EnvelopeType.DOCUMENT,
    },
    include: {
      documentMeta: true,
      recipients: true,
    },
  });

  await triggerWebhook({
    event: WebhookTriggerEvents.DOCUMENT_SIGNED,
    data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(updatedDocument)),
    userId: updatedDocument.userId,
    teamId: updatedDocument.teamId ?? undefined,
  });
};
