import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { PlaceholderInfo } from '@documenso/lib/server-only/pdf/auto-place-fields';
import { convertPlaceholdersToFieldInputs } from '@documenso/lib/server-only/pdf/auto-place-fields';
import { findRecipientByPlaceholder } from '@documenso/lib/server-only/pdf/helpers';
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { DocumentMeta, DocumentVisibility, TemplateType } from '@prisma/client';
import {
  DocumentSource,
  EnvelopeType,
  FolderType,
  RecipientRole,
  SendStatus,
  SigningStatus,
  WebhookTriggerEvents,
} from '@prisma/client';

import type {
  TDocumentAccessAuthTypes,
  TDocumentActionAuthTypes,
  TRecipientAccessAuthTypes,
  TRecipientActionAuthTypes,
} from '../../types/document-auth';
import type { TDocumentFormValues } from '../../types/document-form-values';
import type { TEnvelopeAttachmentType } from '../../types/envelope-attachment';
import type { TFieldAndMeta } from '../../types/field-meta';
import type { TSignatureLevel } from '../../types/signature-level';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { extractDerivedDocumentMeta } from '../../utils/document';
import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth';
import { buildTeamWhereQuery } from '../../utils/teams';
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
import { getTeamSettings } from '../team/get-team-settings';
import { assertUserNotDisabledById } from '../user/assert-user-not-disabled';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';

type CreateEnvelopeRecipientFieldOptions = TFieldAndMeta & {
  documentDataId: string;
  page: number;
  positionX: number;
  positionY: number;
  width: number;
  height: number;
};

type CreateEnvelopeRecipientOptions = {
  email: string;
  name: string;
  role: RecipientRole;
  signingOrder?: number;
  accessAuth?: TRecipientAccessAuthTypes[];
  actionAuth?: TRecipientActionAuthTypes[];
  fields?: CreateEnvelopeRecipientFieldOptions[];
};

export type CreateEnvelopeOptions = {
  userId: number;
  teamId: number;
  normalizePdf?: boolean;
  internalVersion: 1 | 2;
  data: {
    type: EnvelopeType;
    title: string;
    externalId?: string;
    envelopeItems: {
      title?: string;
      documentDataId: string;
      order?: number;
      placeholders?: PlaceholderInfo[];
    }[];
    formValues?: TDocumentFormValues;

    userTimezone?: string;

    templateType?: TemplateType;
    publicTitle?: string;
    publicDescription?: string;

    visibility?: DocumentVisibility;
    globalAccessAuth?: TDocumentAccessAuthTypes[];
    globalActionAuth?: TDocumentActionAuthTypes[];
    recipients?: CreateEnvelopeRecipientOptions[];
    folderId?: string;
    delegatedDocumentOwner?: string;
    signatureLevel?: TSignatureLevel;
  };
  attachments?: Array<{
    label: string;
    data: string;
    type?: TEnvelopeAttachmentType;
  }>;

  /**
   * Whether to bypass adding default recipients.
   *
   * Defaults to false.
   */
  bypassDefaultRecipients?: boolean;

  meta?: Partial<Omit<DocumentMeta, 'id'>>;
  requestMetadata: ApiRequestMetadata;
};

export const createEnvelope = async ({
  userId,
  teamId,
  normalizePdf,
  data,
  attachments,
  meta,
  requestMetadata,
  internalVersion,
  bypassDefaultRecipients = false,
}: CreateEnvelopeOptions) => {
  // Refuse to create on behalf of a disabled account. Guards every route that
  // funnels through here (document.create, envelope.use, template create,
  // embedding template/document create, API v1) and the seed/job paths.
  await assertUserNotDisabledById({ userId });

  const {
    type,
    title,
    externalId,
    formValues,
    userTimezone,
    folderId,
    templateType,
    globalAccessAuth,
    globalActionAuth,
    publicTitle,
    publicDescription,
    visibility: visibilityOverride,
    delegatedDocumentOwner,
    signatureLevel: requestedSignatureLevel,
  } = data;

  const signatureLevel = resolveSignatureLevel({
    requested: requestedSignatureLevel,
    strict: true,
  });

  const team = await prisma.team.findFirst({
    where: buildTeamWhereQuery({ teamId, userId }),
    include: {
      organisation: {
        select: {
          organisationClaim: true,
        },
      },
    },
  });

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

  // Enforce the organisation document-creation limit before doing any work.
  // Only documents count towards the limit (templates are exempt).
  if (type === EnvelopeType.DOCUMENT) {
    await assertOrganisationRatesAndLimits({
      organisationId: team.organisationId,
      organisationClaim: team.organisation.organisationClaim,
      type: 'document',
      count: 1,
    });
  }

  // Verify that the folder exists and is associated with the team.
  if (folderId) {
    const folder = await prisma.folder.findUnique({
      where: {
        id: folderId,
        type: data.type === EnvelopeType.TEMPLATE ? FolderType.TEMPLATE : FolderType.DOCUMENT,
        team: buildTeamWhereQuery({ teamId, userId }),
      },
    });

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

  const settings = await getTeamSettings({
    userId,
    teamId,
  });

  if (data.envelopeItems.length !== 1 && internalVersion === 1) {
    throw new AppError(AppErrorCode.INVALID_BODY, {
      message: 'Envelope items must have exactly 1 item for version 1',
    });
  }

  // CSC / TSP signing flows assume the V2 envelope shape: per-recipient
  // anchors, materialised PDF lineage, sequential signing, mutation lock.
  // The legacy V1 (Document) model can't carry that state, so AES/QES on V1
  // is structurally unsupported and must fail at create time — not later at
  // sign or seal time when the cause is harder to attribute.
  if (signatureLevel !== 'SES' && internalVersion === 1) {
    throw new AppError(AppErrorCode.INVALID_BODY, {
      message: `Envelopes signed at '${signatureLevel}' require internalVersion=2; the legacy V1 envelope shape cannot host TSP signing.`,
    });
  }

  let envelopeItems = data.envelopeItems;

  // Todo: Envelopes - Remove
  if (normalizePdf) {
    envelopeItems = await Promise.all(
      data.envelopeItems.map(async (item) => {
        const documentData = await prisma.documentData.findFirst({
          where: {
            id: item.documentDataId,
          },
        });

        if (!documentData) {
          throw new AppError(AppErrorCode.NOT_FOUND, {
            message: 'Document data not found',
          });
        }

        const buffer = await getFileServerSide(documentData);

        const normalizedPdf = await makeNormalizedPdf(Buffer.from(buffer), {
          flattenForm: type !== EnvelopeType.TEMPLATE,
        });

        const titleToUse = item.title || title;

        const { documentData: newDocumentData } = await putPdfFileServerSide({
          name: titleToUse,
          type: 'application/pdf',
          arrayBuffer: async () => Promise.resolve(normalizedPdf),
        });

        return {
          title: titleToUse.endsWith('.pdf') ? titleToUse.slice(0, -4) : titleToUse,
          documentDataId: newDocumentData.id,
          order: item.order,
        };
      }),
    );
  }

  const authOptions = createDocumentAuthOptions({
    globalAccessAuth: globalAccessAuth || [],
    globalActionAuth: globalActionAuth || [],
  });

  const recipientsHaveActionAuth = data.recipients?.some(
    (recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
  );

  // Check if user has permission to set the global action auth.
  if (
    (authOptions.globalActionAuth.length > 0 || recipientsHaveActionAuth) &&
    !team.organisation.organisationClaim.flags.cfr21
  ) {
    throw new AppError(AppErrorCode.UNAUTHORIZED, {
      message: 'You do not have permission to set the action auth',
    });
  }

  for (const recipient of data.recipients ?? []) {
    assertCompatibleRecipientRole({ signatureLevel, role: recipient.role });
  }

  const visibility = visibilityOverride || settings.documentVisibility;

  const emailId = meta?.emailId;

  // Validate that the email ID belongs to the organisation.
  if (emailId) {
    const email = await prisma.organisationEmail.findFirst({
      where: {
        id: emailId,
        organisationId: team.organisationId,
      },
    });

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

  // userTimezone is last because it's always passed in regardless of the organisation/team settings
  // for uploads from the frontend
  const timezoneToUse = meta?.timezone || settings.documentTimezone || userTimezone;

  const getValidatedDelegatedOwner = async () => {
    if (!settings.delegateDocumentOwnership || !delegatedDocumentOwner || requestMetadata.source === 'app') {
      return null;
    }

    const delegatedOwner = await prisma.user.findFirst({
      where: {
        email: delegatedDocumentOwner,
      },
    });

    if (!delegatedOwner) {
      throw new AppError(AppErrorCode.UNAUTHORIZED, {
        message: 'Delegated document owner must be a member of the team',
      });
    }

    const isTeamMember = await prisma.team.findFirst({
      where: buildTeamWhereQuery({ teamId, userId: delegatedOwner.id }),
    });

    if (!isTeamMember) {
      throw new AppError(AppErrorCode.UNAUTHORIZED, {
        message: 'Delegated document owner must be a member of the team',
      });
    }

    return delegatedOwner;
  };

  const [documentMeta, secondaryId, delegatedOwner] = await Promise.all([
    prisma.documentMeta.create({
      data: extractDerivedDocumentMeta(
        settings,
        {
          ...meta,
          timezone: timezoneToUse,
        },
        signatureLevel,
      ),
    }),
    type === EnvelopeType.DOCUMENT
      ? incrementDocumentId().then((v) => v.formattedDocumentId)
      : incrementTemplateId().then((v) => v.formattedTemplateId),
    getValidatedDelegatedOwner(),
  ]);
  const envelopeOwnerId = delegatedOwner?.id ?? userId;

  const createdEnvelope = await prisma.$transaction(async (tx) => {
    const envelope = await tx.envelope.create({
      data: {
        id: prefixedId('envelope'),
        secondaryId,
        internalVersion,
        type,
        title,
        signatureLevel,
        qrToken: prefixedId('qr'),
        externalId,
        envelopeItems: {
          createMany: {
            data: envelopeItems.map((item, i) => ({
              id: prefixedId('envelope_item'),
              title: item.title || title,
              order: item.order !== undefined ? item.order : i + 1,
              documentDataId: item.documentDataId,
            })),
          },
        },
        envelopeAttachments: {
          createMany: {
            data: (attachments || []).map((attachment) => ({
              label: attachment.label,
              data: attachment.data,
              type: attachment.type ?? 'link',
            })),
          },
        },
        userId: envelopeOwnerId,
        teamId,
        authOptions,
        visibility,
        folderId,
        formValues,
        source: type === EnvelopeType.DOCUMENT ? DocumentSource.DOCUMENT : DocumentSource.TEMPLATE,
        documentMetaId: documentMeta.id,

        // Template specific fields.
        templateType: type === EnvelopeType.TEMPLATE ? templateType : undefined,
        publicTitle: type === EnvelopeType.TEMPLATE ? publicTitle : undefined,
        publicDescription: type === EnvelopeType.TEMPLATE ? publicDescription : undefined,
      },
      include: {
        envelopeItems: true,
      },
    });

    const firstEnvelopeItem = envelope.envelopeItems[0];

    const defaultRecipients =
      settings.defaultRecipients && !bypassDefaultRecipients
        ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients)
        : [];

    const mappedDefaultRecipients: CreateEnvelopeRecipientOptions[] = defaultRecipients.map((recipient) => ({
      email: recipient.email,
      name: recipient.name,
      role: recipient.role,
    }));

    const allRecipients = [...(data.recipients || []), ...mappedDefaultRecipients];

    await Promise.all(
      allRecipients.map(async (recipient) => {
        const recipientAuthOptions = createRecipientAuthOptions({
          accessAuth: recipient.accessAuth ?? [],
          actionAuth: recipient.actionAuth ?? [],
        });

        const recipientFieldsToCreate = (recipient.fields || []).map((field) => {
          let envelopeItemId = firstEnvelopeItem.id;

          if (field.documentDataId) {
            const foundEnvelopeItem = envelope.envelopeItems.find(
              (item) => item.documentDataId === field.documentDataId,
            );

            if (!foundEnvelopeItem) {
              throw new AppError(AppErrorCode.NOT_FOUND, {
                message: 'Document data not found',
              });
            }

            envelopeItemId = foundEnvelopeItem.id;
          }

          return {
            envelopeId: envelope.id,
            envelopeItemId,
            type: field.type,
            page: field.page,
            positionX: field.positionX,
            positionY: field.positionY,
            width: field.width,
            height: field.height,
            customText: '',
            inserted: false,
            fieldMeta: field.fieldMeta || undefined,
          };
        });

        await tx.recipient.create({
          data: {
            envelopeId: envelope.id,
            name: recipient.name,
            email: recipient.email,
            role: recipient.role,
            signingOrder: recipient.signingOrder,
            token: nanoid(),
            sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
            signingStatus: recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
            authOptions: recipientAuthOptions,
            fields: {
              createMany: {
                data: recipientFieldsToCreate,
              },
            },
          },
        });
      }),
    );

    // Create fields from PDF placeholders (extracted at upload time).
    const itemsWithPlaceholders = envelopeItems.filter((item) => item.placeholders && item.placeholders.length > 0);

    if (itemsWithPlaceholders.length > 0) {
      // Collect all unique recipient placeholder references (e.g. "r1", "r2").
      const allPlaceholders = itemsWithPlaceholders.flatMap((item) => item.placeholders ?? []);
      const uniqueRecipientRefs = new Map<number, string>();

      for (const p of allPlaceholders) {
        const match = p.recipient.match(/^r(\d+)$/i);

        if (match) {
          const index = Number(match[1]);

          if (!uniqueRecipientRefs.has(index)) {
            uniqueRecipientRefs.set(index, `Recipient ${index}`);
          }
        }
      }

      // Fetch existing recipients (may have been created above from data.recipients or defaults).
      let availableRecipients = await tx.recipient.findMany({
        where: { envelopeId: envelope.id },
        select: { id: true, email: true },
      });

      const shouldCreatePlaceholderRecipients =
        (!data.recipients || data.recipients.length === 0) && uniqueRecipientRefs.size > 0;

      // If recipients were not provided, create placeholder recipients even when defaults exist.
      if (shouldCreatePlaceholderRecipients) {
        const existingRecipientEmails = new Set(availableRecipients.map((recipient) => recipient.email.toLowerCase()));

        const placeholderRecipients = Array.from(uniqueRecipientRefs.entries(), ([recipientIndex, name]) => ({
          envelopeId: envelope.id,
          email: `recipient.${recipientIndex}@documenso.com`,
          name,
          role: RecipientRole.SIGNER,
          signingOrder: recipientIndex,
          token: nanoid(),
          sendStatus: SendStatus.NOT_SENT,
          signingStatus: SigningStatus.NOT_SIGNED,
        })).filter((recipient) => !existingRecipientEmails.has(recipient.email.toLowerCase()));

        if (placeholderRecipients.length > 0) {
          await tx.recipient.createMany({
            data: placeholderRecipients,
          });

          // eslint-disable-next-line require-atomic-updates
          availableRecipients = await tx.recipient.findMany({
            where: { envelopeId: envelope.id },
            select: { id: true, email: true },
          });
        }
      }

      for (const item of itemsWithPlaceholders) {
        const envelopeItem = envelope.envelopeItems.find((ei) => ei.documentDataId === item.documentDataId);

        if (!envelopeItem) {
          continue;
        }

        const fieldsToCreate = convertPlaceholdersToFieldInputs(
          item.placeholders ?? [],
          (recipientPlaceholder, placeholder) =>
            findRecipientByPlaceholder(
              recipientPlaceholder,
              placeholder,
              data.recipients && data.recipients.length > 0
                ? data.recipients.map((r) => {
                    const found = availableRecipients.find((cr) => cr.email === r.email);

                    if (!found) {
                      throw new AppError(AppErrorCode.NOT_FOUND, {
                        message: `Recipient not found for email: ${r.email}`,
                      });
                    }

                    return found;
                  })
                : undefined,
              availableRecipients,
            ),
          envelopeItem.id,
        );

        if (fieldsToCreate.length > 0) {
          await tx.field.createMany({
            data: fieldsToCreate.map((field) => ({
              envelopeId: envelope.id,
              envelopeItemId: envelopeItem.id,
              recipientId: field.recipientId,
              type: field.type,
              page: field.page,
              positionX: field.positionX,
              positionY: field.positionY,
              width: field.width,
              height: field.height,
              customText: '',
              inserted: false,
              fieldMeta: field.fieldMeta || undefined,
            })),
          });
        }
      }
    }

    const createdEnvelope = await tx.envelope.findFirst({
      where: {
        id: envelope.id,
      },
      include: {
        documentMeta: true,
        recipients: true,
        fields: true,
        folder: true,
        envelopeAttachments: true,
        envelopeItems: {
          include: {
            documentData: true,
          },
        },
      },
    });

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

    // Only create audit logs for documents.
    if (type === EnvelopeType.DOCUMENT) {
      await tx.documentAuditLog.create({
        data: createDocumentAuditLogData({
          type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
          envelopeId: envelope.id,
          user: {
            id: envelopeOwnerId,
          },
          metadata: requestMetadata,
          data: {
            title,
            source: {
              type: DocumentSource.DOCUMENT,
            },
          },
        }),
      });

      // Create audit log for delegated owner if validation passed
      if (delegatedOwner) {
        await tx.documentAuditLog.create({
          data: createDocumentAuditLogData({
            type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELEGATED_OWNER_CREATED,
            envelopeId: envelope.id,
            user: {
              id: userId,
            },
            metadata: requestMetadata,
            data: {
              delegatedOwnerName: delegatedOwner.name,
              delegatedOwnerEmail: delegatedOwner.email,
              teamName: team.name,
            },
          }),
        });
      }
    }

    return createdEnvelope;
  });

  // Trigger webhook outside the transaction to avoid holding the connection
  // open during network I/O.
  if (type === EnvelopeType.DOCUMENT) {
    await triggerWebhook({
      event: WebhookTriggerEvents.DOCUMENT_CREATED,
      data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(createdEnvelope)),
      userId,
      teamId,
    });
  } else if (type === EnvelopeType.TEMPLATE) {
    await triggerWebhook({
      event: WebhookTriggerEvents.TEMPLATE_CREATED,
      data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(createdEnvelope)),
      userId,
      teamId,
    });
  }

  return createdEnvelope;
};
