import os from 'node:os';
import fs, { promises as fsProm } from 'node:fs';
import stream, { promises as streamProm } from 'node:stream';
import type { Readable } from 'node:stream';

import type AWS from '@aws-sdk/client-s3';
import { NoSuchKey, NotFound, S3, type S3ClientConfig } from '@aws-sdk/client-s3';
import debug from 'debug';

import {
	DataStore,
	StreamSplitter,
	Upload,
	ERRORS,
	TUS_RESUMABLE,
	type KvStore,
	MemoryKvStore,
	type ChunkInfo,
} from '../../utils';

import { Semaphore, type Permit } from '@shopify/semaphore';
import MultiStream from 'multistream';
import crypto from 'node:crypto';
import path from 'node:path';

const log = debug('tus-node-server:stores:s3store');

type Options = {
	// The preferred part size for parts send to S3. Can not be lower than 5MiB or more than 5GiB.
	// The server calculates the optimal part size, which takes this size into account,
	// but may increase it to not exceed the S3 10K parts limit.
	partSize?: number;
	useTags?: boolean;
	maxConcurrentPartUploads?: number;
	cache?: KvStore<MetadataValue>;
	expirationPeriodInMilliseconds?: number;
	// Options to pass to the AWS S3 SDK.
	s3ClientConfig: S3ClientConfig & { bucket: string };
};

export type MetadataValue = {
	file: Upload;
	'upload-id': string;
	'tus-version': string;
};

function calcOffsetFromParts(parts?: Array<AWS.Part>) {
	// @ts-expect-error not undefined
	return parts && parts.length > 0 ? parts.reduce((a, b) => a + b.Size, 0) : 0;
}

// Implementation (based on https://github.com/tus/tusd/blob/master/s3store/s3store.go)
//
// Once a new tus upload is initiated, multiple objects in S3 are created:
//
// First of all, a new info object is stored which contains (as Metadata) a JSON-encoded
// blob of general information about the upload including its size and meta data.
// This kind of objects have the suffix ".info" in their key.
//
// In addition a new multipart upload
// (http://docs.aws.amazon.com/AmazonS3/latest/dev/uploadobjusingmpu.html) is
// created. Whenever a new chunk is uploaded to tus-node-server using a PATCH request, a
// new part is pushed to the multipart upload on S3.
//
// If meta data is associated with the upload during creation, it will be added
// to the multipart upload and after finishing it, the meta data will be passed
// to the final object. However, the metadata which will be attached to the
// final object can only contain ASCII characters and every non-ASCII character
// will be replaced by a question mark (for example, "Menü" will be "Men?").
// However, this does not apply for the metadata returned by the `_getMetadata`
// function since it relies on the info object for reading the metadata.
// Therefore, HEAD responses will always contain the unchanged metadata, Base64-
// encoded, even if it contains non-ASCII characters.
//
// Once the upload is finished, the multipart upload is completed, resulting in
// the entire file being stored in the bucket. The info object, containing
// meta data is not deleted.
//
// Considerations
//
// In order to support tus' principle of resumable upload, S3's Multipart-Uploads
// are internally used.
// For each incoming PATCH request (a call to `write`), a new part is uploaded
// to S3.
export class S3Store extends DataStore {
	private bucket: string;
	private cache: KvStore<MetadataValue>;
	private client: S3;
	private preferredPartSize: number;
	private expirationPeriodInMilliseconds = 0;
	private useTags = true;
	private partUploadSemaphore: Semaphore;
	public maxMultipartParts = 10_000 as const;
	public minPartSize = 5_242_880 as const; // 5MiB
	public maxUploadSize = 5_497_558_138_880 as const; // 5TiB

	constructor(options: Options) {
		super();
		const { partSize, s3ClientConfig } = options;
		const { bucket, ...restS3ClientConfig } = s3ClientConfig;
		this.extensions = ['creation', 'creation-with-upload', 'creation-defer-length', 'termination', 'expiration'];
		this.bucket = bucket;
		this.preferredPartSize = partSize || 8 * 1024 * 1024;
		this.expirationPeriodInMilliseconds = options.expirationPeriodInMilliseconds ?? 0;
		this.useTags = options.useTags ?? true;
		this.cache = options.cache ?? new MemoryKvStore<MetadataValue>();
		this.client = new S3(restS3ClientConfig);
		this.partUploadSemaphore = new Semaphore(options.maxConcurrentPartUploads ?? 60);

		// MinIO兼容性检测
		const endpoint = s3ClientConfig.endpoint;
		const isMinIO = endpoint && typeof endpoint === 'string' && endpoint.includes('minio');
		if (isMinIO) {
			console.log('[S3Store] MinIO compatibility mode detected');
			// 对MinIO强制禁用标签功能
			if (this.useTags) {
				console.log('[S3Store] Force disabling tags for MinIO compatibility');
				this.useTags = false;
			}
			// MinIO推荐使用较大的分片大小
			if (this.preferredPartSize < 16 * 1024 * 1024) {
				console.log(
					`[S3Store] Adjusting part size for MinIO compatibility: ${this.preferredPartSize} -> ${16 * 1024 * 1024}`,
				);
				this.preferredPartSize = 16 * 1024 * 1024; // 16MB for MinIO
			}
		}
	}

	protected shouldUseExpirationTags() {
		return this.expirationPeriodInMilliseconds !== 0 && this.useTags;
	}

	protected useCompleteTag(value: 'true' | 'false') {
		if (!this.shouldUseExpirationTags()) {
			return undefined;
		}

		return `Tus-Completed=${value}`;
	}

	/**
	 * Saves upload metadata to a `${file_id}.info` file on S3.
	 * Please note that the file is empty and the metadata is saved
	 * on the S3 object's `Metadata` field, so that only a `headObject`
	 * is necessary to retrieve the data.
	 */
	private async saveMetadata(upload: Upload, uploadId: string) {
		log(`[${upload.id}] saving metadata`);
		console.log(`[S3Store] Saving metadata for upload ${upload.id}, uploadId: ${uploadId}`);
		try {
			const putObjectParams: any = {
				Bucket: this.bucket,
				Key: this.infoKey(upload.id),
				Body: JSON.stringify(upload),
				Metadata: {
					'upload-id': uploadId,
					'tus-version': TUS_RESUMABLE,
				},
			};

			// 只有在启用标签且有过期时间时才添加标签
			const tagging = this.useCompleteTag('false');
			if (tagging) {
				putObjectParams.Tagging = tagging;
			}

			await this.client.putObject(putObjectParams);
			log(`[${upload.id}] metadata file saved`);
			console.log(`[S3Store] Metadata saved successfully for upload ${upload.id}`);
		} catch (error) {
			console.error(`[S3Store] Failed to save metadata for upload ${upload.id}:`, error);
			throw error;
		}
	}

	private async completeMetadata(upload: Upload) {
		if (!this.shouldUseExpirationTags()) {
			return;
		}

		const { 'upload-id': uploadId } = await this.getMetadata(upload.id);

		const putObjectParams: any = {
			Bucket: this.bucket,
			Key: this.infoKey(upload.id),
			Body: JSON.stringify(upload),
			Metadata: {
				'upload-id': uploadId,
				'tus-version': TUS_RESUMABLE,
			},
		};

		// 只有在启用标签且有过期时间时才添加标签
		const tagging = this.useCompleteTag('true');
		if (tagging) {
			putObjectParams.Tagging = tagging;
		}

		await this.client.putObject(putObjectParams);
	}

	/**
	 * Retrieves upload metadata previously saved in `${file_id}.info`.
	 * There's a small and simple caching mechanism to avoid multiple
	 * HTTP calls to S3.
	 */
	private async getMetadata(id: string): Promise<MetadataValue> {
		const cached = await this.cache.get(id);
		if (cached) {
			return cached;
		}

		const { Metadata, Body } = await this.client.getObject({
			Bucket: this.bucket,
			Key: this.infoKey(id),
		});
		const file = JSON.parse((await Body?.transformToString()) as string);
		const metadata: MetadataValue = {
			'tus-version': Metadata?.['tus-version'] as string,
			'upload-id': Metadata?.['upload-id'] as string,
			file: new Upload({
				id,
				size: file.size ? Number.parseInt(file.size, 10) : undefined,
				offset: Number.parseInt(file.offset, 10),
				metadata: file.metadata,
				creation_date: file.creation_date,
				storage: file.storage,
			}),
		};
		await this.cache.set(id, metadata);
		return metadata;
	}

	private infoKey(id: string) {
		return `${id}.info`;
	}

	private partKey(id: string, isIncomplete = false) {
		if (isIncomplete) {
			id += '.part';
		}

		// TODO: introduce ObjectPrefixing for parts and incomplete parts.
		// ObjectPrefix is prepended to the name of each S3 object that is created
		// to store uploaded files. It can be used to create a pseudo-directory
		// structure in the bucket, e.g. "path/to/my/uploads".
		return id;
	}

	private async uploadPart(
		metadata: MetadataValue,
		readStream: fs.ReadStream | Readable,
		partNumber: number,
	): Promise<string> {
		console.log(`[S3Store] Starting upload part #${partNumber} for ${metadata.file.id}`);

		// 针对MinIO兼容性的重试机制
		const maxRetries = 3;
		let lastError: any = null;

		// 获取文件路径（如果是文件流）
		const filePath = readStream instanceof fs.ReadStream ? (readStream as any).path : null;

		for (let attempt = 1; attempt <= maxRetries; attempt++) {
			try {
				// 每次重试都创建新的流
				let bodyStream: fs.ReadStream | Readable;

				if (filePath) {
					// 如果有文件路径，创建新的文件流
					bodyStream = fs.createReadStream(filePath);
					if (attempt > 1) {
						console.log(`[S3Store] Recreating file stream for retry attempt ${attempt}`);
					}
				} else {
					// 如果不是文件流，在第一次尝试后就无法重试
					if (attempt > 1) {
						throw new Error('Cannot retry with non-file stream after first attempt failed');
					}
					bodyStream = readStream;
				}

				const uploadParams: any = {
					Bucket: this.bucket,
					Key: metadata.file.id,
					UploadId: metadata['upload-id'],
					PartNumber: partNumber,
					Body: bodyStream,
				};

				console.log(`[S3Store] Upload attempt ${attempt}/${maxRetries} for part #${partNumber}`);
				const data = await this.client.uploadPart(uploadParams);

				log(`[${metadata.file.id}] finished uploading part #${partNumber}`);
				console.log(`[S3Store] Successfully uploaded part #${partNumber} for ${metadata.file.id}, ETag: ${data.ETag}`);
				return data.ETag as string;
			} catch (error: any) {
				lastError = error;
				console.error(
					`[S3Store] Upload attempt ${attempt}/${maxRetries} failed for part #${partNumber}:`,
					error.message,
				);

				// 特殊处理XML解析错误
				if (error.message && error.message.includes('char') && error.message.includes('not expected')) {
					console.log(`[S3Store] XML parsing error detected - MinIO may have returned HTML instead of XML`);
					console.log(`[S3Store] This usually indicates a server-side issue or API incompatibility`);

					// 对于XML解析错误，也尝试重试
					if (attempt < maxRetries) {
						const delay = Math.pow(2, attempt) * 1000;
						console.log(`[S3Store] Retrying after XML parse error, waiting ${delay}ms...`);
						await new Promise((resolve) => setTimeout(resolve, delay));
						continue;
					}
				}

				// 检查是否是501错误
				if (error.$metadata?.httpStatusCode === 501) {
					console.log(`[S3Store] Received 501 error on attempt ${attempt}, this may be a MinIO compatibility issue`);

					// 如果是501错误且是第一个分片，尝试使用简单上传作为回退
					if (partNumber === 1 && attempt === maxRetries) {
						console.log(`[S3Store] Attempting fallback to simple upload for ${metadata.file.id}`);
						try {
							// 取消当前的multipart upload
							await this.client.abortMultipartUpload({
								Bucket: this.bucket,
								Key: metadata.file.id,
								UploadId: metadata['upload-id'],
							});

							// 重新创建流
							let fallbackStream: fs.ReadStream | Readable;
							if (filePath) {
								fallbackStream = fs.createReadStream(filePath);
							} else {
								// 如果不是文件流，无法回退
								throw new Error('Cannot fallback to simple upload with non-file stream');
							}

							// 尝试使用简单的putObject
							const putResult = await this.client.putObject({
								Bucket: this.bucket,
								Key: metadata.file.id,
								Body: fallbackStream,
								ContentType: metadata.file.metadata?.contentType || undefined,
							});

							console.log(
								`[S3Store] Simple upload successful for ${metadata.file.id}, ETag: ${putResult.ETag || 'unknown'}`,
							);

							// 标记为已完成，避免后续分片上传
							if (metadata.file.size) {
								metadata.file.offset = metadata.file.size;
							}

							return putResult.ETag || 'fallback-etag';
						} catch (fallbackError: any) {
							console.error(`[S3Store] Fallback to simple upload failed: ${fallbackError.message}`);
							// 继续原来的错误处理流程
						}
					}

					// 如果是501错误且不是最后一次重试，等待一下再重试
					if (attempt < maxRetries) {
						const delay = Math.pow(2, attempt) * 1000; // 指数退避
						console.log(`[S3Store] Waiting ${delay}ms before retry...`);
						await new Promise((resolve) => setTimeout(resolve, delay));
						continue;
					}
				}

				// 如果是其他错误，立即抛出
				if (
					error.$metadata?.httpStatusCode !== 501 &&
					!(error.message && error.message.includes('char') && error.message.includes('not expected'))
				) {
					throw error;
				}

				// 如果是最后一次重试的501错误或XML解析错误
				if (attempt === maxRetries) {
					let errorMessage = '';
					if (error.$metadata?.httpStatusCode === 501) {
						errorMessage = `MinIO compatibility issue: Received HTTP 501 after ${maxRetries} attempts. `;
					} else if (error.message && error.message.includes('char') && error.message.includes('not expected')) {
						errorMessage = `MinIO XML parsing issue: Server returned non-XML content after ${maxRetries} attempts. `;
					}

					const enhancedError = new Error(
						errorMessage +
							`This may indicate that your MinIO version does not support this S3 API operation. ` +
							`Consider upgrading MinIO or adjusting upload parameters. Original error: ${error.message}`,
					);
					// 保留原始错误的元数据
					(enhancedError as any).$metadata = error.$metadata;
					(enhancedError as any).originalError = error;
					throw enhancedError;
				}
			}
		}

		// 这行不应该被执行到，但为了类型安全
		throw lastError;
	}

	private async uploadIncompletePart(id: string, readStream: fs.ReadStream | Readable): Promise<string> {
		console.log(`[S3Store] Starting upload incomplete part for ${id}`);
		try {
			const putObjectParams: any = {
				Bucket: this.bucket,
				Key: this.partKey(id, true),
				Body: readStream,
			};

			// 只有在启用标签且有过期时间时才添加标签
			const tagging = this.useCompleteTag('false');
			if (tagging) {
				putObjectParams.Tagging = tagging;
			}

			const data = await this.client.putObject(putObjectParams);
			log(`[${id}] finished uploading incomplete part`);
			console.log(`[S3Store] Successfully uploaded incomplete part for ${id}, ETag: ${data.ETag}`);
			return data.ETag as string;
		} catch (error) {
			console.error(`[S3Store] Failed to upload incomplete part for ${id}:`, error);
			throw error;
		}
	}

	private async downloadIncompletePart(id: string) {
		const incompletePart = await this.getIncompletePart(id);

		if (!incompletePart) {
			return;
		}
		const filePath = await this.uniqueTmpFileName('tus-s3-incomplete-part-');

		try {
			let incompletePartSize = 0;

			const byteCounterTransform = new stream.Transform({
				transform(chunk, _, callback) {
					incompletePartSize += chunk.length;
					callback(null, chunk);
				},
			});

			// write to temporary file
			await streamProm.pipeline(incompletePart, byteCounterTransform, fs.createWriteStream(filePath));

			const createReadStream = (options: { cleanUpOnEnd: boolean }) => {
				const fileReader = fs.createReadStream(filePath);

				if (options.cleanUpOnEnd) {
					fileReader.on('end', () => {
						fs.unlink(filePath, () => {
							// ignore
						});
					});

					fileReader.on('error', (err) => {
						fileReader.destroy(err);
						fs.unlink(filePath, () => {
							// ignore
						});
					});
				}

				return fileReader;
			};

			return {
				size: incompletePartSize,
				path: filePath,
				createReader: createReadStream,
			};
		} catch (err) {
			fsProm.rm(filePath).catch(() => {
				/* ignore */
			});
			throw err;
		}
	}

	private async getIncompletePart(id: string): Promise<Readable | undefined> {
		try {
			const data = await this.client.getObject({
				Bucket: this.bucket,
				Key: this.partKey(id, true),
			});
			return data.Body as Readable;
		} catch (error) {
			if (error instanceof NoSuchKey) {
				return undefined;
			}

			throw error;
		}
	}

	private async getIncompletePartSize(id: string): Promise<number | undefined> {
		try {
			const data = await this.client.headObject({
				Bucket: this.bucket,
				Key: this.partKey(id, true),
			});
			return data.ContentLength;
		} catch (error) {
			if (error instanceof NotFound) {
				return undefined;
			}
			throw error;
		}
	}

	private async deleteIncompletePart(id: string): Promise<void> {
		await this.client.deleteObject({
			Bucket: this.bucket,
			Key: this.partKey(id, true),
		});
	}

	/**
	 * Uploads a stream to s3 using multiple parts
	 */
	private async uploadParts(
		metadata: MetadataValue,
		readStream: stream.Readable,
		currentPartNumber: number,
		offset: number,
	): Promise<number> {
		console.log(
			`[S3Store] uploadParts starting for ${metadata.file.id}, currentPartNumber: ${currentPartNumber}, offset: ${offset}`,
		);

		const size = metadata.file.size;
		const promises: Promise<void>[] = [];
		let pendingChunkFilepath: string | null = null;
		let bytesUploaded = 0;
		let permit: Permit | undefined = undefined;

		const optimalPartSize = this.calcOptimalPartSize(size);
		console.log(`[S3Store] Using optimal part size: ${optimalPartSize} bytes for ${metadata.file.id}`);

		const splitterStream = new StreamSplitter({
			chunkSize: optimalPartSize,
			directory: os.tmpdir(),
		})
			.on('beforeChunkStarted', async () => {
				console.log(`[S3Store] Acquiring semaphore permit for ${metadata.file.id}`);
				permit = await this.partUploadSemaphore.acquire();
			})
			.on('chunkStarted', (filepath) => {
				console.log(`[S3Store] Chunk started for ${metadata.file.id}, file: ${filepath}`);
				pendingChunkFilepath = filepath;
			})
			.on('chunkFinished', (chunkInfo: ChunkInfo) => {
				const { size: partSize, path } = chunkInfo;
				console.log(`[S3Store] Chunk finished for ${metadata.file.id}, size: ${partSize}, path: ${path}`);
				pendingChunkFilepath = null;

				const acquiredPermit = permit;
				const partNumber = currentPartNumber++;

				offset += partSize;

				const isFinalPart = size === offset;
				console.log(
					`[S3Store] Processing part #${partNumber} for ${metadata.file.id}, isFinalPart: ${isFinalPart}, partSize: ${partSize}`,
				);

				// biome-ignore lint/suspicious/noAsyncPromiseExecutor: it's fine
				const deferred = new Promise<void>(async (resolve, reject) => {
					try {
						// Only the first chunk of each PATCH request can prepend
						// an incomplete part (last chunk) from the previous request.
						if (!path) {
							reject(new Error(`Chunk path is null or undefined for ${metadata.file.id}, part #${partNumber}`));
							return;
						}
						const readable = fs.createReadStream(path);
						readable.on('error', reject);

						if (partSize >= this.minPartSize || isFinalPart) {
							console.log(`[S3Store] Uploading part #${partNumber} for ${metadata.file.id} (${partSize} bytes)`);
							await this.uploadPart(metadata, readable, partNumber);
						} else {
							console.log(`[S3Store] Uploading incomplete part for ${metadata.file.id} (${partSize} bytes)`);
							await this.uploadIncompletePart(metadata.file.id, readable);
						}

						bytesUploaded += partSize;
						console.log(
							`[S3Store] Part upload completed for ${metadata.file.id}, total bytes uploaded: ${bytesUploaded}`,
						);
						resolve();
					} catch (error) {
						console.error(`[S3Store] Part upload failed for ${metadata.file.id}, part #${partNumber}:`, error);
						reject(error);
					} finally {
						if (path) {
							fsProm.rm(path).catch(() => {
								/* ignore */
							});
						}
						acquiredPermit?.release();
					}
				});

				promises.push(deferred);
			})
			.on('chunkError', (error) => {
				console.error(`[S3Store] Chunk error for ${metadata.file.id}:`, error);
				permit?.release();
			});

		try {
			console.log(`[S3Store] Starting stream pipeline for ${metadata.file.id}`);
			await streamProm.pipeline(readStream, splitterStream);
			console.log(`[S3Store] Stream pipeline completed for ${metadata.file.id}`);
		} catch (error) {
			console.error(`[S3Store] Stream pipeline failed for ${metadata.file.id}:`, error);
			if (pendingChunkFilepath !== null) {
				try {
					await fsProm.rm(pendingChunkFilepath);
				} catch {
					log(`[${metadata.file.id}] failed to remove chunk ${pendingChunkFilepath}`);
				}
			}

			promises.push(Promise.reject(error));
		} finally {
			console.log(`[S3Store] Waiting for all part uploads to complete for ${metadata.file.id}`);
			await Promise.all(promises);
			console.log(`[S3Store] All part uploads completed for ${metadata.file.id}`);
		}

		console.log(`[S3Store] uploadParts completed for ${metadata.file.id}, total bytes uploaded: ${bytesUploaded}`);
		return bytesUploaded;
	}

	/**
	 * Completes a multipart upload on S3.
	 * This is where S3 concatenates all the uploaded parts.
	 */
	private async finishMultipartUpload(metadata: MetadataValue, parts: Array<AWS.Part>) {
		const response = await this.client.completeMultipartUpload({
			Bucket: this.bucket,
			Key: metadata.file.id,
			UploadId: metadata['upload-id'],
			MultipartUpload: {
				Parts: parts.map((part) => {
					return {
						ETag: part.ETag,
						PartNumber: part.PartNumber,
					};
				}),
			},
		});
		return response.Location;
	}

	/**
	 * Gets the number of complete parts/chunks already uploaded to S3.
	 * Retrieves only consecutive parts.
	 */
	private async retrieveParts(id: string, partNumberMarker?: string): Promise<Array<AWS.Part>> {
		const metadata = await this.getMetadata(id);

		const params: AWS.ListPartsCommandInput = {
			Bucket: this.bucket,
			Key: id,
			UploadId: metadata['upload-id'],
			PartNumberMarker: partNumberMarker,
		};

		const data = await this.client.listParts(params);

		let parts = data.Parts ?? [];

		if (data.IsTruncated) {
			const rest = await this.retrieveParts(id, data.NextPartNumberMarker);
			parts = [...parts, ...rest];
		}

		if (!partNumberMarker) {
			// biome-ignore lint/style/noNonNullAssertion: it's fine
			parts.sort((a, b) => a.PartNumber! - b.PartNumber!);
		}

		return parts;
	}

	/**
	 * Removes cached data for a given file.
	 */
	private async clearCache(id: string) {
		log(`[${id}] removing cached data`);
		await this.cache.delete(id);
	}

	private calcOptimalPartSize(size?: number): number {
		// When upload size is not know we assume largest possible value (`maxUploadSize`)
		if (size === undefined) {
			size = this.maxUploadSize;
		}

		let optimalPartSize: number;

		// When upload is smaller or equal to PreferredPartSize, we upload in just one part.
		if (size <= this.preferredPartSize) {
			optimalPartSize = size;
		}
		// Does the upload fit in MaxMultipartParts parts or less with PreferredPartSize.
		else if (size <= this.preferredPartSize * this.maxMultipartParts) {
			optimalPartSize = this.preferredPartSize;
			// The upload is too big for the preferred size.
			// We devide the size with the max amount of parts and round it up.
		} else {
			optimalPartSize = Math.ceil(size / this.maxMultipartParts);
		}

		return optimalPartSize;
	}

	/**
	 * Creates a multipart upload on S3 attaching any metadata to it.
	 * Also, a `${file_id}.info` file is created which holds some information
	 * about the upload itself like: `upload-id`, `upload-length`, etc.
	 */
	public async create(upload: Upload) {
		log(`[${upload.id}] initializing multipart upload`);
		console.log(`[S3Store] Creating multipart upload for ${upload.id}, bucket: ${this.bucket}`);

		const request: AWS.CreateMultipartUploadCommandInput = {
			Bucket: this.bucket,
			Key: upload.id,
			Metadata: { 'tus-version': TUS_RESUMABLE },
		};

		if (upload.metadata?.contentType) {
			request.ContentType = upload.metadata.contentType;
			console.log(`[S3Store] Setting ContentType: ${upload.metadata.contentType}`);
		}

		if (upload.metadata?.cacheControl) {
			request.CacheControl = upload.metadata.cacheControl;
		}

		upload.creation_date = new Date().toISOString();

		try {
			console.log(`[S3Store] Sending createMultipartUpload request for ${upload.id}`);
			const res = await this.client.createMultipartUpload(request);
			console.log(`[S3Store] Multipart upload created successfully, UploadId: ${res.UploadId}`);

			upload.storage = {
				type: 's3',
				path: res.Key as string,
				bucket: this.bucket,
			};

			await this.saveMetadata(upload, res.UploadId as string);
			log(`[${upload.id}] multipart upload created (${res.UploadId})`);
			console.log(`[S3Store] Upload creation completed for ${upload.id}`);

			return upload;
		} catch (error) {
			console.error(`[S3Store] Failed to create multipart upload for ${upload.id}:`, error);
			throw error;
		}
	}

	async read(id: string) {
		const data = await this.client.getObject({
			Bucket: this.bucket,
			Key: id,
		});
		return data.Body as Readable;
	}

	/**
	 * Write to the file, starting at the provided offset
	 */
	public async write(src: stream.Readable, id: string, offset: number): Promise<number> {
		console.log(`[S3Store] Starting write operation for ${id}, offset: ${offset}`);

		try {
			// Metadata request needs to happen first
			console.log(`[S3Store] Retrieving metadata for ${id}`);
			const metadata = await this.getMetadata(id);
			console.log(`[S3Store] Retrieved metadata for ${id}, file size: ${metadata.file.size}`);

			const parts = await this.retrieveParts(id);
			console.log(`[S3Store] Retrieved ${parts.length} existing parts for ${id}`);

			// biome-ignore lint/style/noNonNullAssertion: it's fine
			const partNumber: number = parts.length > 0 ? (parts[parts.length - 1]?.PartNumber ?? 0) : 0;
			const nextPartNumber = partNumber + 1;
			console.log(`[S3Store] Next part number will be: ${nextPartNumber}`);

			const incompletePart = await this.downloadIncompletePart(id);
			const requestedOffset = offset;

			if (incompletePart) {
				console.log(`[S3Store] Found incomplete part for ${id}, size: ${incompletePart.size}`);
				// once the file is on disk, we delete the incomplete part
				await this.deleteIncompletePart(id);

				offset = requestedOffset - incompletePart.size;
				src = new MultiStream([incompletePart.createReader({ cleanUpOnEnd: true }), src]);
			}

			console.log(`[S3Store] Starting uploadParts for ${id}`);
			const bytesUploaded = await this.uploadParts(metadata, src, nextPartNumber, offset);
			console.log(`[S3Store] uploadParts completed for ${id}, bytes uploaded: ${bytesUploaded}`);

			// The size of the incomplete part should not be counted, because the
			// process of the incomplete part should be fully transparent to the user.
			const newOffset = requestedOffset + bytesUploaded - (incompletePart?.size ?? 0);
			console.log(`[S3Store] New offset for ${id}: ${newOffset}, file size: ${metadata.file.size}`);

			if (metadata.file.size === newOffset) {
				console.log(`[S3Store] Upload completed for ${id}, finishing multipart upload`);
				try {
					const parts = await this.retrieveParts(id);
					console.log(`[S3Store] Retrieved ${parts.length} parts for completion`);

					await this.finishMultipartUpload(metadata, parts);
					console.log(`[S3Store] Multipart upload finished successfully for ${id}`);

					await this.completeMetadata(metadata.file);
					console.log(`[S3Store] Metadata completed for ${id}`);

					await this.clearCache(id);
					console.log(`[S3Store] Cache cleared for ${id}`);
				} catch (error) {
					log(`[${id}] failed to finish upload`, error);
					console.error(`[S3Store] Failed to finish upload for ${id}:`, error);
					throw error;
				}
			}

			return newOffset;
		} catch (error) {
			console.error(`[S3Store] Write operation failed for ${id}:`, error);
			throw error;
		}
	}

	public async getUpload(id: string): Promise<Upload> {
		let metadata: MetadataValue;
		try {
			metadata = await this.getMetadata(id);
		} catch (error) {
			log('getUpload: No file found.', error);
			throw ERRORS.FILE_NOT_FOUND;
		}

		let offset = 0;

		try {
			const parts = await this.retrieveParts(id);
			offset = calcOffsetFromParts(parts);
		} catch (error: any) {
			// Check if the error is caused by the upload not being found. This happens
			// when the multipart upload has already been completed or aborted. Since
			// we already found the info object, we know that the upload has been
			// completed and therefore can ensure the the offset is the size.
			// AWS S3 returns NoSuchUpload, but other implementations, such as DigitalOcean
			// Spaces, can also return NoSuchKey.
			if (error.Code === 'NoSuchUpload' || error.Code === 'NoSuchKey') {
				return new Upload({
					...metadata.file,
					offset: metadata.file.size as number,
					size: metadata.file.size,
					metadata: metadata.file.metadata,
					storage: metadata.file.storage,
				});
			}

			log(error);
			throw error;
		}

		const incompletePartSize = await this.getIncompletePartSize(id);

		return new Upload({
			...metadata.file,
			offset: offset + (incompletePartSize ?? 0),
			size: metadata.file.size,
			storage: metadata.file.storage,
		});
	}

	public async declareUploadLength(file_id: string, upload_length: number) {
		const { file, 'upload-id': uploadId } = await this.getMetadata(file_id);
		if (!file) {
			throw ERRORS.FILE_NOT_FOUND;
		}

		file.size = upload_length;

		await this.saveMetadata(file, uploadId);
	}

	public async remove(id: string): Promise<void> {
		try {
			const { 'upload-id': uploadId } = await this.getMetadata(id);
			if (uploadId) {
				await this.client.abortMultipartUpload({
					Bucket: this.bucket,
					Key: id,
					UploadId: uploadId,
				});
			}
		} catch (error: any) {
			if (error?.code && ['NotFound', 'NoSuchKey', 'NoSuchUpload'].includes(error.Code)) {
				log('remove: No file found.', error);
				throw ERRORS.FILE_NOT_FOUND;
			}
			throw error;
		}

		await this.client.deleteObjects({
			Bucket: this.bucket,
			Delete: {
				Objects: [{ Key: id }, { Key: this.infoKey(id) }],
			},
		});

		this.clearCache(id);
	}

	protected getExpirationDate(created_at: string) {
		const date = new Date(created_at);

		return new Date(date.getTime() + this.getExpiration());
	}

	getExpiration(): number {
		return this.expirationPeriodInMilliseconds;
	}

	async deleteExpired(): Promise<number> {
		if (this.getExpiration() === 0) {
			return 0;
		}

		let keyMarker: string | undefined = undefined;
		let uploadIdMarker: string | undefined = undefined;
		let isTruncated = true;
		let deleted = 0;

		while (isTruncated) {
			const listResponse: AWS.ListMultipartUploadsCommandOutput = await this.client.listMultipartUploads({
				Bucket: this.bucket,
				KeyMarker: keyMarker,
				UploadIdMarker: uploadIdMarker,
			});

			const expiredUploads =
				listResponse.Uploads?.filter((multiPartUpload) => {
					const initiatedDate = multiPartUpload.Initiated;
					return initiatedDate && new Date().getTime() > this.getExpirationDate(initiatedDate.toISOString()).getTime();
				}) || [];

			const objectsToDelete = expiredUploads.reduce(
				(all, expiredUpload) => {
					all.push(
						{
							key: this.infoKey(expiredUpload.Key as string),
						},
						{
							key: this.partKey(expiredUpload.Key as string, true),
						},
					);
					return all;
				},
				[] as { key: string }[],
			);

			const deletions: Promise<AWS.DeleteObjectsCommandOutput>[] = [];

			// Batch delete 1000 items at a time
			while (objectsToDelete.length > 0) {
				const objects = objectsToDelete.splice(0, 1000);
				deletions.push(
					this.client.deleteObjects({
						Bucket: this.bucket,
						Delete: {
							Objects: objects.map((object) => ({
								Key: object.key,
							})),
						},
					}),
				);
			}

			const [objectsDeleted] = await Promise.all([
				Promise.all(deletions),
				...expiredUploads.map((expiredUpload) => {
					return this.client.abortMultipartUpload({
						Bucket: this.bucket,
						Key: expiredUpload.Key,
						UploadId: expiredUpload.UploadId,
					});
				}),
			]);

			deleted += objectsDeleted.reduce((all, acc) => all + (acc.Deleted?.length ?? 0), 0);

			isTruncated = Boolean(listResponse.IsTruncated);

			if (isTruncated) {
				keyMarker = listResponse.NextKeyMarker;
				uploadIdMarker = listResponse.NextUploadIdMarker;
			}
		}

		return deleted;
	}

	private async uniqueTmpFileName(template: string): Promise<string> {
		let tries = 0;
		const maxTries = 10;

		while (tries < maxTries) {
			const fileName = template + crypto.randomBytes(10).toString('base64url').slice(0, 10);
			const filePath = path.join(os.tmpdir(), fileName);

			try {
				await fsProm.lstat(filePath);
				// If no error, file exists, so try again
				tries++;
			} catch (e: any) {
				if (e.code === 'ENOENT') {
					// File does not exist, return the path
					return filePath;
				}
				throw e; // For other errors, rethrow
			}
		}

		throw new Error(`Could not find a unique file name after ${maxTries} tries`);
	}
}
