import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { updateEnvelope } from '@documenso/lib/server-only/envelope/update-envelope';
import { UNSAFE_createEnvelopeItems } from '@documenso/lib/server-only/envelope-item/create-envelope-items';
import { UNSAFE_deleteEnvelopeItem } from '@documenso/lib/server-only/envelope-item/delete-envelope-item';
import { UNSAFE_replaceEnvelopeItemPdf } from '@documenso/lib/server-only/envelope-item/replace-envelope-item-pdf';
import { UNSAFE_updateEnvelopeItems } from '@documenso/lib/server-only/envelope-item/update-envelope-items';
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
import { setFieldsForTemplate } from '@documenso/lib/server-only/field/set-fields-for-template';
import { setDocumentRecipients } from '@documenso/lib/server-only/recipient/set-document-recipients';
import { setTemplateRecipients } from '@documenso/lib/server-only/recipient/set-template-recipients';
import { nanoid } from '@documenso/lib/universal/id';
import { PRESIGNED_ENVELOPE_ITEM_ID_PREFIX } from '@documenso/lib/utils/embed-config';
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import pMap from 'p-map';
import { match } from 'ts-pattern';

import { procedure } from '../trpc';
import {
  ZUpdateEmbeddingEnvelopeRequestSchema,
  ZUpdateEmbeddingEnvelopeResponseSchema,
} from './update-embedding-envelope.types';

export const updateEmbeddingEnvelopeRoute = procedure
  .input(ZUpdateEmbeddingEnvelopeRequestSchema)
  .output(ZUpdateEmbeddingEnvelopeResponseSchema)
  .mutation(async ({ input, ctx }) => {
    const { payload, files } = input;
    const { envelopeId, data, meta } = payload;

    ctx.logger.info({
      input: {
        envelopeId,
      },
    });

    const authorizationHeader = ctx.req.headers.get('authorization');

    const [presignToken] = (authorizationHeader || '').split('Bearer ').filter((s) => s.length > 0);

    if (!presignToken) {
      throw new AppError(AppErrorCode.UNAUTHORIZED, {
        message: 'No presign token provided',
      });
    }

    const apiToken = await verifyEmbeddingPresignToken({
      token: presignToken,
      scope: `envelopeId:${envelopeId}`,
    });

    const { envelopeWhereInput } = await getEnvelopeWhereInput({
      id: {
        type: 'envelopeId',
        id: envelopeId,
      },
      type: null, // Allow updating both documents and templates.
      userId: apiToken.userId,
      teamId: apiToken.teamId,
    });

    const envelope = await prisma.envelope.findFirst({
      where: envelopeWhereInput,
      include: {
        envelopeItems: true,
        team: {
          select: {
            organisation: {
              select: {
                organisationClaim: true,
              },
            },
          },
        },
        recipients: true,
        envelopeAttachments: true,
      },
    });

    if (!envelope) {
      throw new AppError(AppErrorCode.NOT_FOUND, {
        message: 'Envelope not found',
      });
    }

    if (envelope.status === DocumentStatus.COMPLETED) {
      throw new AppError(AppErrorCode.INVALID_REQUEST, {
        message: 'Cannot modify completed envelope',
      });
    }

    // Step 1: Update the envelope items.
    const envelopeItemsToUpdate: EnvelopeItemUpdateOptions[] = [];
    const envelopeItemsToCreate: EnvelopeItemCreateOptions[] = [];
    const envelopeItemsToReplace: EnvelopeItemReplaceOptions[] = [];

    // Sort and group envelope items to update, create, and replace.
    data.envelopeItems.forEach((item) => {
      const isNewEnvelopeItem = item.id.startsWith(PRESIGNED_ENVELOPE_ITEM_ID_PREFIX);

      // Handle existing envelope items.
      if (!isNewEnvelopeItem) {
        const envelopeItem = envelope.envelopeItems.find((envelopeItem) => envelopeItem.id === item.id);

        if (!envelopeItem) {
          throw new AppError(AppErrorCode.NOT_FOUND, {
            message: 'Envelope item not found',
          });
        }

        // Check if this existing item has a replacement file.
        if (item.replaceFileIndex !== undefined) {
          const replaceFile = files[item.replaceFileIndex];

          if (!replaceFile) {
            throw new AppError(AppErrorCode.INVALID_BODY, {
              message: 'Invalid replace file index',
            });
          }

          envelopeItemsToReplace.push({
            envelopeItemId: envelopeItem.id,
            oldDocumentDataId: envelopeItem.documentDataId,
            title: item.title,
            order: item.order,
            file: replaceFile,
          });

          return;
        }

        const hasEnvelopeItemChanged = envelopeItem.title !== item.title || envelopeItem.order !== item.order;

        if (hasEnvelopeItemChanged) {
          envelopeItemsToUpdate.push({
            envelopeItemId: envelopeItem.id,
            title: item.title,
            order: item.order,
          });
        }

        // Return to continue loop.
        return;
      }

      const newEnvelopeItemFile = item.index !== undefined ? files[item.index] : undefined;

      if (!newEnvelopeItemFile) {
        throw new AppError(AppErrorCode.INVALID_BODY, {
          message: 'Invalid envelope item index',
        });
      }

      // Handle not yet uploaded envelope items.
      envelopeItemsToCreate.push({
        embeddedEnvelopeItemId: item.id,
        title: item.title,
        order: item.order,
        file: newEnvelopeItemFile,
      });
    });

    // Delete envelope items that have been removed from the payload.
    const envelopeItemIdsToDelete = envelope.envelopeItems
      .filter((item) => !data.envelopeItems.some((i) => i.id === item.id))
      .map((item) => item.id);

    const willEnvelopeItemsBeModified =
      envelopeItemIdsToDelete.length > 0 ||
      envelopeItemsToCreate.length > 0 ||
      envelopeItemsToUpdate.length > 0 ||
      envelopeItemsToReplace.length > 0;

    const organisationClaim = envelope.team.organisation.organisationClaim;
    const resultingEnvelopeItemCount =
      envelope.envelopeItems.length - envelopeItemIdsToDelete.length + envelopeItemsToCreate.length;

    if (resultingEnvelopeItemCount > organisationClaim.envelopeItemCount) {
      throw new AppError('ENVELOPE_ITEM_LIMIT_EXCEEDED', {
        message: `You cannot upload more than ${organisationClaim.envelopeItemCount} envelope items`,
        statusCode: 400,
      });
    }

    // Should be safe to use stale envelope.recipients since only signed or sent
    // recipients affect the outcome.
    if (willEnvelopeItemsBeModified) {
      const permissions = getEnvelopeItemPermissions(envelope, envelope.recipients);

      const hasFileChange = envelopeItemIdsToDelete.length > 0 || envelopeItemsToCreate.length > 0;

      const hasOrderChange = envelopeItemsToUpdate.some((item) => {
        const existing = envelope.envelopeItems.find((e) => e.id === item.envelopeItemId);

        return !existing || item.order !== existing.order;
      });

      const hasTitleChange = envelopeItemsToUpdate.some((item) => item.title !== undefined);

      if (hasFileChange && !permissions.canFileBeChanged) {
        throw new AppError(AppErrorCode.INVALID_REQUEST, {
          message: 'Envelope item files are not editable',
        });
      }

      if (hasOrderChange && !permissions.canOrderBeChanged) {
        throw new AppError(AppErrorCode.INVALID_REQUEST, {
          message: 'Envelope item order is not editable',
        });
      }

      if (hasTitleChange && !permissions.canTitleBeChanged) {
        throw new AppError(AppErrorCode.INVALID_REQUEST, {
          message: 'Envelope item title is not editable',
        });
      }
    }

    if (envelopeItemIdsToDelete.length > 0) {
      await pMap(
        envelopeItemIdsToDelete,
        async (envelopeItemId) => {
          await UNSAFE_deleteEnvelopeItem({
            envelopeId: envelope.id,
            envelopeItemId,
            user: apiToken.user,
            apiRequestMetadata: ctx.metadata,
          });
        },
        { concurrency: 2 },
      );
    }

    // Mapping for the client side embedded prefix envelope item IDs to the real envelope item IDs.
    const embeddedEnvelopeItemIdMapping: Record<string, string> = {};

    // Create new envelope items.
    if (envelopeItemsToCreate.length > 0) {
      const createdEnvelopeItems = await UNSAFE_createEnvelopeItems({
        files: envelopeItemsToCreate.map((item) => ({
          clientId: item.embeddedEnvelopeItemId,
          file: item.file,
          orderOverride: item.order,
        })),
        envelope: {
          ...envelope,
          // Purposefully putting empty recipients here since placeholders should automatically injected on the client side for
          // embedded purposes. Todo: Embeds - (Not implemeneted yet)
          recipients: [],
        },
        user: {
          id: apiToken.user.id,
          name: apiToken.user.name,
          email: apiToken.user.email,
        },
        apiRequestMetadata: ctx.metadata,
      });

      // Build the map from the envelope item order.
      createdEnvelopeItems.forEach((item) => {
        if (!item.clientId) {
          throw new AppError(AppErrorCode.NOT_FOUND, {
            message: 'Client ID not found',
          });
        }

        embeddedEnvelopeItemIdMapping[item.clientId] = item.id;
      });
    }

    if (envelopeItemsToUpdate.length > 0) {
      await UNSAFE_updateEnvelopeItems({
        envelopeId: envelope.id,
        envelopeType: envelope.type,
        existingEnvelopeItems: envelope.envelopeItems,
        data: envelopeItemsToUpdate,
        user: {
          name: apiToken.user.name,
          email: apiToken.user.email,
        },
        apiRequestMetadata: ctx.metadata,
      });
    }

    // Replace PDFs for existing envelope items without creating placeholder fields
    // field cleanup is handled in later steps.
    if (envelopeItemsToReplace.length > 0) {
      await pMap(
        envelopeItemsToReplace,
        async (item) => {
          await UNSAFE_replaceEnvelopeItemPdf({
            envelope,
            recipients: [],
            envelopeItemId: item.envelopeItemId,
            oldDocumentDataId: item.oldDocumentDataId,
            data: {
              title: item.title,
              order: item.order,
              file: item.file,
            },
            user: apiToken.user,
            apiRequestMetadata: ctx.metadata,
          });
        },
        { concurrency: 2 },
      );
    }

    // Step 2: Update the general envelope data and meta.
    await updateEnvelope({
      userId: apiToken.userId,
      teamId: apiToken.teamId,
      id: {
        type: 'envelopeId',
        id: envelope.id,
      },
      data: {
        title: data.title,
        externalId: data.externalId,
        visibility: data.visibility,
        globalAccessAuth: data.globalAccessAuth,
        globalActionAuth: data.globalActionAuth,
        folderId: data.folderId,
      },
      meta,
      requestMetadata: ctx.metadata,
    });

    // Step 3: Update the recipients
    const recipientsWithClientId = data.recipients.map((recipient) => ({
      ...recipient,
      clientId: nanoid(),
    }));

    const { recipients: updatedRecipients } = await match(envelope.type)
      .with(EnvelopeType.DOCUMENT, async () =>
        setDocumentRecipients({
          userId: apiToken.userId,
          teamId: apiToken.teamId,
          id: {
            type: 'envelopeId',
            id: envelope.id,
          },
          recipients: recipientsWithClientId.map((recipient) => ({
            id: recipient.id,
            clientId: recipient.clientId,
            email: recipient.email,
            name: recipient.name ?? '',
            role: recipient.role,
            signingOrder: recipient.signingOrder,
            actionAuth: recipient.actionAuth,
          })),
          requestMetadata: ctx.metadata,
        }),
      )
      .with(EnvelopeType.TEMPLATE, async () =>
        setTemplateRecipients({
          userId: apiToken.userId,
          teamId: apiToken.teamId,
          id: {
            type: 'envelopeId',
            id: envelope.id,
          },
          recipients: recipientsWithClientId.map((recipient) => ({
            id: recipient.id,
            clientId: recipient.clientId,
            email: recipient.email,
            name: recipient.name ?? '',
            role: recipient.role,
            signingOrder: recipient.signingOrder,
            actionAuth: recipient.actionAuth,
          })),
        }),
      )
      .exhaustive();

    // Step 4: Update the fields.
    const fields = recipientsWithClientId.flatMap((recipient) => {
      const recipientId = updatedRecipients.find((r) => r.clientId === recipient.clientId)?.id;

      if (!recipientId) {
        throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
          message: 'Recipient not found',
        });
      }

      return (recipient.fields ?? []).map((field) => {
        let envelopeItemId = field.envelopeItemId;

        if (envelopeItemId.startsWith(PRESIGNED_ENVELOPE_ITEM_ID_PREFIX)) {
          envelopeItemId = embeddedEnvelopeItemIdMapping[envelopeItemId];
        }

        if (!envelopeItemId) {
          throw new AppError(AppErrorCode.NOT_FOUND, {
            message: 'Envelope item not found',
          });
        }

        return {
          ...field,
          recipientId,
          envelopeItemId,
        };
      });
    });

    await match(envelope.type)
      .with(EnvelopeType.DOCUMENT, async () =>
        setFieldsForDocument({
          userId: apiToken.userId,
          teamId: apiToken.teamId,
          id: {
            type: 'envelopeId',
            id: envelopeId,
          },
          fields: fields.map((field) => ({
            ...field,
            pageNumber: field.page,
            pageX: field.positionX,
            pageY: field.positionY,
            pageWidth: field.width,
            pageHeight: field.height,
          })),
          requestMetadata: ctx.metadata,
        }),
      )
      .with(EnvelopeType.TEMPLATE, async () =>
        setFieldsForTemplate({
          userId: apiToken.userId,
          teamId: apiToken.teamId,
          id: {
            type: 'envelopeId',
            id: envelopeId,
          },
          fields: fields.map((field) => ({
            ...field,
            pageNumber: field.page,
            pageX: field.positionX,
            pageY: field.positionY,
            pageWidth: field.width,
            pageHeight: field.height,
          })),
        }),
      )
      .exhaustive();

    // Step 5: Handle attachments (set semantics: delete all existing, create new).
    let hasEnvelopeAttachmentsChanged = envelope.envelopeAttachments.length !== data.attachments.length;

    data.attachments.forEach((attachment) => {
      const foundAttachment = envelope.envelopeAttachments.find((a) => a.id === attachment.id);

      if (!foundAttachment) {
        hasEnvelopeAttachmentsChanged = true;
        return;
      }

      const hasAttachmentChanged =
        foundAttachment.label !== attachment.label ||
        foundAttachment.data !== attachment.data ||
        foundAttachment.type !== attachment.type;

      if (hasAttachmentChanged) {
        hasEnvelopeAttachmentsChanged = true;
        return;
      }
    });

    if (hasEnvelopeAttachmentsChanged) {
      await prisma.envelopeAttachment.deleteMany({
        where: {
          envelopeId: envelope.id,
        },
      });

      if (data.attachments.length > 0) {
        await prisma.envelopeAttachment.createMany({
          data: data.attachments.map((attachment) => ({
            envelopeId: envelope.id,
            label: attachment.label,
            data: attachment.data,
            type: attachment.type,
          })),
        });
      }
    }
  });

type EnvelopeItemUpdateOptions = {
  envelopeItemId: string;
  title?: string;
  order?: number;
};

type EnvelopeItemCreateOptions = {
  embeddedEnvelopeItemId: string;
  title: string;
  order: number;
  file: File;
};

type EnvelopeItemReplaceOptions = {
  envelopeItemId: string;
  oldDocumentDataId: string;
  title: string;
  order: number;
  file: File;
};
