import { randomUUID } from "crypto";

import { env } from "@/src/env.mjs";
import { getFileExtensionFromContentType } from "@/src/features/media/server/getFileExtensionFromContentType";
import { getMediaStorageServiceClient } from "@/src/features/media/server/getMediaStorageClient";
import {
  GetMediaUploadUrlQuerySchema,
  GetMediaUploadUrlResponseSchema,
  type MediaContentType,
} from "@/src/features/media/validation";
import { createAuthedProjectAPIRoute } from "@/src/features/public-api/server/createAuthedProjectAPIRoute";
import { withMiddlewares } from "@/src/features/public-api/server/withMiddlewares";
import {
  ForbiddenError,
  InternalServerError,
  InvalidRequestError,
} from "@langfuse/shared";
import { prisma } from "@langfuse/shared/src/db";
import { logger, instrumentAsync } from "@langfuse/shared/src/server";

export default withMiddlewares({
  POST: createAuthedProjectAPIRoute({
    name: "Get Media Upload URL",
    bodySchema: GetMediaUploadUrlQuerySchema,
    responseSchema: GetMediaUploadUrlResponseSchema,
    successStatusCode: 201,
    rateLimitResource: "ingestion",
    fn: async ({ body, auth }) => {
      // Check if ingestion is suspended due to usage threshold
      if (auth.scope.isIngestionSuspended) {
        throw new ForbiddenError(
          "Ingestion suspended: Usage threshold exceeded. Please upgrade your plan.",
        );
      }

      if (auth.scope.accessLevel !== "project") throw new ForbiddenError();

      const { projectId } = auth.scope;
      const {
        contentType,
        contentLength,
        sha256Hash,
        traceId,
        observationId,
        field,
      } = body;

      if (contentLength > env.LANGFUSE_S3_MEDIA_MAX_CONTENT_LENGTH)
        throw new InvalidRequestError(
          `File size must be less than ${env.LANGFUSE_S3_MEDIA_MAX_CONTENT_LENGTH} bytes`,
        );

      return await instrumentAsync(
        { name: "media-create-upload-url" },
        async (span) => {
          span.setAttribute("projectId", projectId);
          span.setAttribute("traceId", traceId);
          span.setAttribute("observationId", observationId ?? "");
          span.setAttribute("field", field);
          span.setAttribute("sha256Hash", sha256Hash);

          try {
            const existingMedia = await prisma.media.findUnique({
              where: {
                projectId_sha256Hash: {
                  projectId,
                  sha256Hash,
                },
              },
            });

            if (
              existingMedia &&
              existingMedia.uploadHttpStatus === 200 &&
              existingMedia.contentType === contentType
            ) {
              span.setAttribute("mediaId", existingMedia.id);

              if (observationId) {
                // Use raw upserts to avoid deadlocks
                await prisma.$queryRaw`
              INSERT INTO "observation_media" ("id", "project_id", "trace_id", "observation_id", "media_id", "field")
              VALUES (${randomUUID()}, ${projectId}, ${traceId}, ${observationId}, ${existingMedia.id}, ${field})
              ON CONFLICT DO NOTHING;
            `;
              } else {
                // Use raw upserts to avoid deadlocks
                await prisma.$queryRaw`
              INSERT INTO "trace_media" ("id", "project_id", "trace_id", "media_id", "field")
              VALUES (${randomUUID()}, ${projectId}, ${traceId}, ${existingMedia.id}, ${field})
              ON CONFLICT DO NOTHING;
            `;
              }

              return {
                mediaId: existingMedia.id,
                uploadUrl: null,
              };
            }

            const mediaId = existingMedia?.id ?? getMediaId({ sha256Hash });

            span.setAttribute("mediaId", mediaId);

            if (!env.LANGFUSE_S3_MEDIA_UPLOAD_BUCKET)
              throw new InternalServerError(
                "Media upload to blob storage not enabled or no bucket configured",
              );

            const s3Client = getMediaStorageServiceClient(
              env.LANGFUSE_S3_MEDIA_UPLOAD_BUCKET,
            );

            const bucketPath = getBucketPath({
              projectId,
              mediaId,
              contentType,
            });

            const uploadUrl = await s3Client.getSignedUploadUrl({
              path: bucketPath,
              ttlSeconds: 60 * 60, // 1 hour
              sha256Hash,
              contentType,
              contentLength,
            });

            // Create media record first to ensure fkey constraint is met on next queries
            // Under high concurrency, the upsert might fail due to the multiple uniqueness constraints
            // (id and (project_id ad sha_256))
            // See also: https://stackoverflow.com/questions/73164161/insert-on-conflict-do-update-set-an-upsert-statement-with-a-unique-constraint
            const maxRetries = 3;
            const delayMs = 100;
            let retryCount = 0;

            while (retryCount < maxRetries) {
              try {
                await prisma.$queryRaw`
                  INSERT INTO "media" (
                      "id",
                      "project_id",
                      "sha_256_hash",
                      "bucket_path",
                      "bucket_name",
                      "content_type",
                      "content_length"
                    )
                    VALUES (
                      ${mediaId},
                      ${projectId},
                      ${sha256Hash},
                      ${bucketPath},
                      ${env.LANGFUSE_S3_MEDIA_UPLOAD_BUCKET},
                      ${contentType},
                      ${contentLength}
                    )
                    ON CONFLICT ("project_id", "sha_256_hash")
                    DO UPDATE SET
                      "bucket_name" = ${env.LANGFUSE_S3_MEDIA_UPLOAD_BUCKET},
                      "bucket_path" = ${bucketPath},
                      "content_type" = ${contentType},
                      "content_length" = ${contentLength}
                  `;
                break;
              } catch (e) {
                retryCount += 1;

                if (retryCount >= maxRetries) throw e;

                logger.debug(
                  `Failed to create media record. Retrying (${retryCount}/${maxRetries})...`,
                );

                await new Promise((resolve) => setTimeout(resolve, delayMs));
              }
            }

            if (observationId) {
              await prisma.$queryRaw`
                INSERT INTO "observation_media" ("id", "project_id", "trace_id", "observation_id", "media_id", "field")
                VALUES (${randomUUID()}, ${projectId}, ${traceId}, ${observationId}, ${mediaId}, ${field})
                ON CONFLICT DO NOTHING;
            `;
            } else {
              await prisma.$queryRaw`
                INSERT INTO "trace_media" ("id", "project_id", "trace_id", "media_id", "field")
                VALUES (${randomUUID()}, ${projectId}, ${traceId}, ${mediaId}, ${field})
                ON CONFLICT DO NOTHING;
            `;
            }

            return {
              mediaId,
              uploadUrl,
            };
          } catch {
            logger.error(
              `Failed to get media upload URL for trace ${traceId} and observation ${observationId}.`,
            );
            throw new InternalServerError("Failed to get media upload URL");
          }
        },
      );
    },
  }),
});

function getBucketPath(params: {
  projectId: string;
  mediaId: string;
  contentType: MediaContentType;
}): string {
  const { projectId, mediaId, contentType } = params;
  const fileExtension = getFileExtensionFromContentType(contentType);

  const prefix = env.LANGFUSE_S3_MEDIA_UPLOAD_PREFIX
    ? `${env.LANGFUSE_S3_MEDIA_UPLOAD_PREFIX}`
    : "";

  return `${prefix}${projectId}/${mediaId}.${fileExtension}`;
}

function getMediaId(params: { sha256Hash: string }) {
  const { sha256Hash } = params;

  // Make hash URL safe
  const urlSafeHash = sha256Hash.replaceAll("+", "-").replaceAll("/", "_");

  // Get first 132 bits, i.e. first 22 base64Url chars
  return urlSafeHash.slice(0, 22);
}
