import "server-only";

import {
  AbortMultipartUploadCommand,
  CompleteMultipartUploadCommand,
  CreateMultipartUploadCommand,
  DeleteObjectCommand,
  GetObjectCommand,
  HeadObjectCommand,
  ListPartsCommand,
  PutObjectCommand,
  S3Client,
  UploadPartCommand,
  type ServiceInputTypes,
  type ServiceOutputTypes,
} from "@aws-sdk/client-s3";
import type { FinalizeRequestMiddleware } from "@smithy/types";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { readUploadParts } from "@/lib/private-upload-chunks";

type ObjectStorageConfig = {
  accessKeyId: string;
  bucketName: string;
  cdnDomain: string | null;
  endpoint: string;
  region: string;
  secretAccessKey: string;
};

let objectStorageClient: S3Client | null = null;

const ignoreInvalidStorageResponseDate: FinalizeRequestMiddleware<ServiceInputTypes, ServiceOutputTypes> =
  (next) => async (args) => {
    const output = await next(args);
    const response = output.response;
    const headers = response && typeof response === "object" && "headers" in response &&
        response.headers && typeof response.headers === "object"
      ? response.headers as Record<string, string>
      : null;
    const serverDate = headers?.date ?? headers?.Date;
    if (headers && serverDate) {
      const serverTime = Date.parse(serverDate);
      if (!Number.isFinite(serverTime) || Math.abs(serverTime - Date.now()) > 24 * 60 * 60 * 1000) {
        delete headers.date;
        delete headers.Date;
      }
    }
    return output;
  };

function readRequiredEnv(name: string) {
  const value = process.env[name]?.trim();

  if (!value) {
    throw new Error(`${name}-missing`);
  }

  return value;
}

function readOptionalEnv(name: string) {
  const value = process.env[name]?.trim();
  return value ? value : null;
}

function getObjectStorageConfig(): ObjectStorageConfig {
  return {
    accessKeyId: readRequiredEnv("NCP_ACCESS_KEY"),
    bucketName: readRequiredEnv("NCP_BUCKET_NAME"),
    cdnDomain: readOptionalEnv("CDN_DOMAIN"),
    endpoint: readRequiredEnv("NCP_ENDPOINT"),
    region: readOptionalEnv("NCP_REGION") ?? "kr-standard",
    secretAccessKey: readRequiredEnv("NCP_SECRET_KEY"),
  };
}

function getObjectStorageClient() {
  if (!objectStorageClient) {
    const config = getObjectStorageConfig();

    objectStorageClient = new S3Client({
      credentials: {
        accessKeyId: config.accessKeyId,
        secretAccessKey: config.secretAccessKey,
      },
      endpoint: config.endpoint,
      forcePathStyle: true,
      region: config.region,
    });
    // NCP Object Storage can return an epoch Date on multipart part responses.
    // The AWS signer treats it as clock skew and signs the next request in 1970.
    // Ignore only implausible response dates; keep normal skew correction intact.
    objectStorageClient.middlewareStack.addRelativeTo(
      ignoreInvalidStorageResponseDate,
      { relation: "after", toMiddleware: "httpSigningMiddleware", name: "ignoreInvalidStorageResponseDate" },
    );
  }

  return objectStorageClient;
}

function encodeStorageKey(key: string) {
  return key
    .split("/")
    .map((segment) => encodeURIComponent(segment))
    .join("/");
}

export function buildObjectStoragePublicUrl(key: string) {
  const config = getObjectStorageConfig();
  const encodedKey = encodeStorageKey(key);

  if (config.cdnDomain) {
    return `${config.cdnDomain.replace(/\/$/, "")}/${encodedKey}`;
  }

  return `${config.endpoint.replace(/\/$/, "")}/${encodeURIComponent(config.bucketName)}/${encodedKey}`;
}

export async function uploadBufferToObjectStorage(input: {
  body: Buffer;
  cacheControl?: string;
  contentType: string;
  key: string;
}) {
  const config = getObjectStorageConfig();
  const client = getObjectStorageClient();

  await client.send(
    new PutObjectCommand({
      ACL: "public-read",
      Body: input.body,
      Bucket: config.bucketName,
      CacheControl: input.cacheControl,
      ContentType: input.contentType,
      Key: input.key,
    }),
  );

  return {
    bucketName: config.bucketName,
    key: input.key,
    publicUrl: buildObjectStoragePublicUrl(input.key),
  };
}

export async function uploadPrivateBufferToObjectStorage(input: {
  body: Buffer;
  cacheControl?: string;
  contentType: string;
  key: string;
}) {
  const config = getObjectStorageConfig();
  const client = getObjectStorageClient();

  await client.send(
    new PutObjectCommand({
      ACL: "private",
      Body: input.body,
      Bucket: config.bucketName,
      CacheControl: input.cacheControl,
      ContentType: input.contentType,
      Key: input.key,
    }),
  );

  return {
    bucketName: config.bucketName,
    key: input.key,
  };
}

export async function uploadPrivateStreamToObjectStorage(input: {
  body: ReadableStream<Uint8Array>;
  contentType: string;
  expectedBytes: number;
  key: string;
}) {
  if (!Number.isSafeInteger(input.expectedBytes) || input.expectedBytes < 1) {
    throw new Error("upload-size-invalid");
  }
  const config = getObjectStorageConfig();
  const client = getObjectStorageClient();
  const created = await client.send(new CreateMultipartUploadCommand({
    ACL: "private",
    Bucket: config.bucketName,
    ContentType: input.contentType,
    Key: input.key,
  }));
  const uploadId = created.UploadId;
  if (!uploadId) throw new Error("upload-session-missing");

  const completedParts: Array<{ ETag: string; PartNumber: number }> = [];
  const sendPart = async (part: Buffer) => {
    const partNumber = completedParts.length + 1;
    const uploaded = await client.send(new UploadPartCommand({
      Body: part,
      Bucket: config.bucketName,
      ContentLength: part.length,
      Key: input.key,
      PartNumber: partNumber,
      UploadId: uploadId,
    }));
    if (!uploaded.ETag) throw new Error("upload-part-etag-missing");
    completedParts.push({ ETag: uploaded.ETag, PartNumber: partNumber });
  };

  try {
    for await (const part of readUploadParts(input.body, input.expectedBytes)) {
      await sendPart(part);
    }
    await client.send(new CompleteMultipartUploadCommand({
      Bucket: config.bucketName,
      Key: input.key,
      MultipartUpload: { Parts: completedParts },
      UploadId: uploadId,
    }));
    return { bucketName: config.bucketName, key: input.key };
  } catch (error) {
    await client.send(new AbortMultipartUploadCommand({
      Bucket: config.bucketName,
      Key: input.key,
      UploadId: uploadId,
    })).catch(() => undefined);
    throw error;
  }
}

export const DIRECT_MULTIPART_PART_BYTES = 16 * 1024 * 1024;

export async function createPrivateMultipartUpload(input: { contentType: string; key: string }) {
  const config = getObjectStorageConfig();
  const response = await getObjectStorageClient().send(new CreateMultipartUploadCommand({
    ACL: "private",
    Bucket: config.bucketName,
    ContentType: input.contentType,
    Key: input.key,
  }));
  if (!response.UploadId) throw new Error("upload-session-missing");
  return { bucketName: config.bucketName, uploadId: response.UploadId };
}

export async function presignPrivateMultipartParts(input: {
  key: string;
  partCount: number;
  uploadId: string;
}) {
  const config = getObjectStorageConfig();
  if (!Number.isSafeInteger(input.partCount) || input.partCount < 1 || input.partCount > 16) {
    throw new Error("upload-part-count-invalid");
  }
  return Promise.all(Array.from({ length: input.partCount }, async (_, index) => ({
    partNumber: index + 1,
    url: await getSignedUrl(getObjectStorageClient(), new UploadPartCommand({
      Bucket: config.bucketName,
      Key: input.key,
      PartNumber: index + 1,
      UploadId: input.uploadId,
    }), { expiresIn: 3600 }),
  })));
}

export async function completePrivateMultipartUpload(input: {
  expectedBytes: number;
  key: string;
  uploadId: string;
}) {
  const config = getObjectStorageConfig();
  const client = getObjectStorageClient();
  const expectedCount = Math.ceil(input.expectedBytes / DIRECT_MULTIPART_PART_BYTES);
  const listed = await client.send(new ListPartsCommand({
    Bucket: config.bucketName,
    Key: input.key,
    UploadId: input.uploadId,
  }));
  const parts = listed.Parts ?? [];
  if (listed.IsTruncated || parts.length !== expectedCount) throw new Error("upload-parts-incomplete");
  let listedBytes = 0;
  for (let index = 0; index < parts.length; index += 1) {
    const expectedPartBytes = Math.min(
      DIRECT_MULTIPART_PART_BYTES,
      input.expectedBytes - index * DIRECT_MULTIPART_PART_BYTES,
    );
    if (parts[index].PartNumber !== index + 1 || !parts[index].ETag ||
        parts[index].Size !== expectedPartBytes) {
      throw new Error("upload-parts-incomplete");
    }
    listedBytes += parts[index].Size ?? 0;
  }
  if (listedBytes !== input.expectedBytes) throw new Error("upload-size-mismatch");
  await client.send(new CompleteMultipartUploadCommand({
    Bucket: config.bucketName,
    Key: input.key,
    MultipartUpload: {
      Parts: parts.map((part) => ({ ETag: part.ETag, PartNumber: part.PartNumber })),
    },
    UploadId: input.uploadId,
  }));
  const head = await client.send(new HeadObjectCommand({ Bucket: config.bucketName, Key: input.key }));
  if (head.ContentLength !== input.expectedBytes) throw new Error("upload-size-mismatch");
  return { bucketName: config.bucketName, key: input.key };
}

export async function abortPrivateMultipartUpload(input: { key: string; uploadId: string }) {
  const config = getObjectStorageConfig();
  await getObjectStorageClient().send(new AbortMultipartUploadCommand({
    Bucket: config.bucketName,
    Key: input.key,
    UploadId: input.uploadId,
  }));
}

export async function readObjectFromObjectStorage(input: { key: string }) {
  const config = getObjectStorageConfig();
  const client = getObjectStorageClient();
  const response = await client.send(
    new GetObjectCommand({
      Bucket: config.bucketName,
      Key: input.key,
    }),
  );

  if (!response.Body) {
    return null;
  }

  const bytes = await response.Body.transformToByteArray();

  return {
    body: Buffer.from(bytes),
    contentType: response.ContentType?.trim() || null,
  };
}

export async function streamObjectFromObjectStorage(input: { key: string }) {
  const config = getObjectStorageConfig();
  const response = await getObjectStorageClient().send(
    new GetObjectCommand({ Bucket: config.bucketName, Key: input.key }),
  );
  if (!response.Body) return null;
  return {
    body: response.Body.transformToWebStream(),
    contentLength: response.ContentLength ?? null,
    contentType: response.ContentType?.trim() || "application/octet-stream",
  };
}

export async function deleteObjectFromObjectStorage(input: { key: string }) {
  const config = getObjectStorageConfig();
  const client = getObjectStorageClient();

  await client.send(
    new DeleteObjectCommand({
      Bucket: config.bucketName,
      Key: input.key,
    }),
  );
}
