// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import {
	KeyValueStorageInterface,
	StorageAccessLevel,
} from '@aws-amplify/core';

import { UPLOADS_STORAGE_KEY } from '../../../../utils/constants';
import { ResolvedS3Config } from '../../../../types/options';
import { Part, listParts } from '../../../../utils/client/s3data';
import { logger } from '../../../../../../utils';
// TODO: Remove this interface when we move to public advanced APIs.
import { UploadDataInput as UploadDataWithPathInputWithAdvancedOptions } from '../../../../../../internals/types/inputs';
import { getContentType } from '../../../../../../utils/contentType';

const ONE_HOUR = 1000 * 60 * 60;

interface FindCachedUploadPartsOptions {
	cacheKey: string;
	s3Config: ResolvedS3Config;
	bucket: string;
	finalKey: string;
	resumableUploadsCache: KeyValueStorageInterface;
}

/**
 * Find the cached multipart upload id and get the parts that have been uploaded
 * with ListParts API. If the cached upload is expired(1 hour), return null.
 */
export const findCachedUploadPartsAndEvictExpired = async ({
	resumableUploadsCache,
	cacheKey,
	s3Config,
	bucket,
	finalKey,
}: FindCachedUploadPartsOptions): Promise<{
	parts: Part[];
	uploadId: string;
	finalCrc32?: string;
} | null> => {
	const allCachedUploads = await listCachedUploadTasks(resumableUploadsCache);
	// Evict all outdated uploads.
	const validCachedUploads = Object.fromEntries(
		Object.entries(allCachedUploads).filter(
			([_, cacheValue]) => cacheValue.lastTouched >= Date.now() - ONE_HOUR,
		),
	);
	if (
		Object.keys(validCachedUploads).length !==
		Object.keys(allCachedUploads).length
	) {
		await resumableUploadsCache.setItem(
			UPLOADS_STORAGE_KEY,
			JSON.stringify(validCachedUploads),
		);
	}

	if (!validCachedUploads[cacheKey]) {
		return null;
	}

	const cachedUpload = validCachedUploads[cacheKey];
	cachedUpload.lastTouched = Date.now();

	await resumableUploadsCache.setItem(
		UPLOADS_STORAGE_KEY,
		JSON.stringify(validCachedUploads),
	);

	try {
		const { Parts = [] } = await listParts(s3Config, {
			Bucket: bucket,
			Key: finalKey,
			UploadId: cachedUpload.uploadId,
		});

		return {
			parts: Parts,
			uploadId: cachedUpload.uploadId,
			finalCrc32: cachedUpload.finalCrc32,
		};
	} catch (e) {
		logger.debug('failed to list cached parts, removing cached upload.');
		await removeCachedUpload(resumableUploadsCache, cacheKey);

		return null;
	}
};

interface FileMetadata {
	bucket: string;
	fileName: string;
	key: string;
	uploadId: string;
	finalCrc32?: string;
	// Unix timestamp in ms
	lastTouched: number;
}

const listCachedUploadTasks = async (
	resumableUploadsCache: KeyValueStorageInterface,
): Promise<Record<string, FileMetadata>> => {
	try {
		return JSON.parse(
			(await resumableUploadsCache.getItem(UPLOADS_STORAGE_KEY)) ?? '{}',
		);
	} catch (e) {
		logger.debug('failed to parse cached uploads record.');

		return {};
	}
};

/**
 * Serialize the uploadData API options to string so it can be hashed.
 */
export const serializeUploadOptions = (
	options: UploadDataWithPathInputWithAdvancedOptions['options'] & {
		resumableUploadsCache?: KeyValueStorageInterface;
	} = {},
): string => {
	const unserializableOptionProperties: string[] = [
		'onProgress',
		'resumableUploadsCache', // Internally injected implementation not set by customers
		'locationCredentialsProvider', // Internally injected implementation not set by customers
	] satisfies (keyof typeof options)[];
	const serializableOptionEntries = Object.entries(options).filter(
		([key]) => !unserializableOptionProperties.includes(key),
	);

	if (options.checksumAlgorithm === 'crc-32') {
		// Additional options to differentiate the upload cache created before introducing the full-object checksum and
		// after. If full-object checksum is enabled, the previous upload caches that created with composite checksum should
		// be ignored.
		serializableOptionEntries.push(['checksumType', 'FULL_OBJECT']);
	}

	const serializableOptions = Object.fromEntries(serializableOptionEntries);

	return JSON.stringify(serializableOptions);
};

interface UploadsCacheKeyOptions {
	size: number;
	contentType?: string;
	bucket: string;
	accessLevel?: StorageAccessLevel;
	key: string;
	file?: File;
	optionsHash: string;
}

/**
 * Get the cache key of a multipart upload. Data source cached by different: size, content type, bucket, access level,
 * key. If the data source is a File instance, the upload is additionally indexed by file name and last modified time.
 * So the library always created a new multipart upload if the file is modified.
 */
export const getUploadsCacheKey = ({
	file,
	size,
	contentType,
	bucket,
	accessLevel,
	key,
	optionsHash,
}: UploadsCacheKeyOptions) => {
	let levelStr;
	const resolvedContentType =
		contentType ?? getContentType(file, key) ?? 'application/octet-stream';

	// If no access level is defined, we're using custom gen2 access rules
	if (accessLevel === undefined) {
		levelStr = 'custom';
	} else {
		levelStr = accessLevel === 'guest' ? 'public' : accessLevel;
	}

	const baseId = `${optionsHash}_${size}_${resolvedContentType}_${bucket}_${levelStr}_${key}`;

	if (file) {
		return `${file.name}_${file.lastModified}_${baseId}`;
	} else {
		return baseId;
	}
};

export const cacheMultipartUpload = async (
	resumableUploadsCache: KeyValueStorageInterface,
	cacheKey: string,
	fileMetadata: Omit<FileMetadata, 'lastTouched'>,
): Promise<void> => {
	const cachedUploads = await listCachedUploadTasks(resumableUploadsCache);
	cachedUploads[cacheKey] = {
		...fileMetadata,
		lastTouched: Date.now(),
	};
	await resumableUploadsCache.setItem(
		UPLOADS_STORAGE_KEY,
		JSON.stringify(cachedUploads),
	);
};

export const removeCachedUpload = async (
	resumableUploadsCache: KeyValueStorageInterface,
	cacheKey: string,
): Promise<void> => {
	const cachedUploads = await listCachedUploadTasks(resumableUploadsCache);
	delete cachedUploads[cacheKey];
	await resumableUploadsCache.setItem(
		UPLOADS_STORAGE_KEY,
		JSON.stringify(cachedUploads),
	);
};
