import { open } from 'fs/promises';
import { createReadStream } from 'fs';
import { createHash } from 'crypto';
import path from 'path';

/**
 * Maps allowed MIME types to their permitted magic-byte signatures.
 * Each entry is [byteOffset, hexPattern].
 * Only MIME types that are accepted by the multer fileFilter are listed.
 */
const MAGIC_SIGNATURES: Record<string, Array<[number, Buffer]>> = {
	'image/jpeg': [[0, Buffer.from([0xff, 0xd8, 0xff])]],
	'image/jpg': [[0, Buffer.from([0xff, 0xd8, 0xff])]],
	'image/png': [[0, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])]],
	'image/webp': [[8, Buffer.from([0x57, 0x45, 0x42, 0x50])]], // "WEBP" at offset 8 in RIFF container
	'video/mp4': [
		[4, Buffer.from([0x66, 0x74, 0x79, 0x70])], // 'ftyp' box
	],
	'video/quicktime': [[4, Buffer.from([0x66, 0x74, 0x79, 0x70])]],
	'video/webm': [[0, Buffer.from([0x1a, 0x45, 0xdf, 0xa3])]],
	'application/pdf': [[0, Buffer.from([0x25, 0x50, 0x44, 0x46])]], // '%PDF'
	'application/zip': [[0, Buffer.from([0x50, 0x4b, 0x03, 0x04])]],
	'application/x-zip-compressed': [[0, Buffer.from([0x50, 0x4b, 0x03, 0x04])]],
	'application/vnd.rar': [[0, Buffer.from([0x52, 0x61, 0x72, 0x21])]],
	'application/x-rar-compressed': [[0, Buffer.from([0x52, 0x61, 0x72, 0x21])]],
	// Office docs (PK zip container)
	'application/vnd.openxmlformats-officedocument.wordprocessingml.document': [
		[0, Buffer.from([0x50, 0x4b, 0x03, 0x04])],
	],
	'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': [[0, Buffer.from([0x50, 0x4b, 0x03, 0x04])]],
	// Legacy Office (CFB container)
	'application/msword': [[0, Buffer.from([0xd0, 0xcf, 0x11, 0xe0])]],
	'application/vnd.ms-excel': [[0, Buffer.from([0xd0, 0xcf, 0x11, 0xe0])]],
};

// MIME types for which we skip magic-byte check (complex containers we don't want false-positives)
const SKIP_MAGIC_CHECK = new Set([
	'video/x-msvideo',
	'video/x-ms-wmv',
	'video/x-matroska',
	'video/mpeg',
	'video/3gpp',
	'video/3gpp2',
	'video/ogg',
]);

// Dangerous extensions that should never be allowed regardless of MIME
const BLOCKED_EXTENSIONS = new Set([
	'.exe',
	'.sh',
	'.bat',
	'.cmd',
	'.ps1',
	'.msi',
	'.vbs',
	'.js',
	'.ts',
	'.php',
	'.py',
	'.rb',
	'.pl',
	'.cgi',
	'.htaccess',
	'.env',
]);

/**
 * Sanitize a filename to prevent path traversal and shell injection.
 * Returns a safe ASCII-only filename.
 */
export function sanitizeFilename(original: string): string {
	const ext = path.extname(original).toLowerCase();
	const base = path.basename(original, ext);

	// Strip path separators, null bytes and dangerous chars
	const safeBase = base
		.replace(/[^a-zA-Z0-9_\-\u00C0-\u024F\u4E00-\u9FFF]/g, '_')
		.replace(/_{2,}/g, '_')
		.slice(0, 100);

	const safeExt = /^\.[a-zA-Z0-9]{1,10}$/.test(ext) ? ext : '';

	return `${safeBase || 'file'}${safeExt}`;
}

/**
 * Returns true if the file extension is explicitly blocked.
 */
export function isBlockedExtension(filename: string): boolean {
	const ext = path.extname(filename).toLowerCase();
	return BLOCKED_EXTENSIONS.has(ext);
}

/**
 * Maps MIME types to their allowed file extensions.
 * Used to cross-validate declared MIME against actual file extension,
 * preventing exploits where a dangerous file is renamed to a safe extension.
 */
const MIME_TO_ALLOWED_EXTENSIONS: Record<string, Set<string>> = {
	'image/jpeg': new Set(['.jpg', '.jpeg']),
	'image/jpg': new Set(['.jpg', '.jpeg']),
	'image/png': new Set(['.png']),
	'image/webp': new Set(['.webp']),
	'image/gif': new Set(['.gif']),
	'video/mp4': new Set(['.mp4']),
	'video/quicktime': new Set(['.mov', '.qt']),
	'video/x-msvideo': new Set(['.avi']),
	'video/x-ms-wmv': new Set(['.wmv']),
	'video/x-matroska': new Set(['.mkv']),
	'video/webm': new Set(['.webm']),
	'video/mpeg': new Set(['.mpeg', '.mpg']),
	'video/3gpp': new Set(['.3gp']),
	'video/3gpp2': new Set(['.3g2']),
	'video/ogg': new Set(['.ogv', '.ogg']),
	'application/pdf': new Set(['.pdf']),
	'application/msword': new Set(['.doc']),
	'application/vnd.openxmlformats-officedocument.wordprocessingml.document': new Set(['.docx']),
	'application/vnd.ms-excel': new Set(['.xls']),
	'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': new Set(['.xlsx']),
	'application/zip': new Set(['.zip']),
	'application/x-zip-compressed': new Set(['.zip']),
	'application/x-rar-compressed': new Set(['.rar']),
	'application/vnd.rar': new Set(['.rar']),
};

/**
 * Validates that the file extension is consistent with the declared MIME type.
 * Prevents attacks where an executable is renamed to an image extension,
 * and helps catch mismatched uploads before they reach disk.
 *
 * @param filename - Original filename from the client
 * @param declaredMime - MIME type as reported by multer (from Content-Type)
 * @returns true if extension matches MIME, or if MIME has no extension mapping
 */
export function validateMimeExtensionMatch(filename: string, declaredMime: string): boolean {
	const ext = path.extname(filename).toLowerCase();
	const allowedExts = MIME_TO_ALLOWED_EXTENSIONS[declaredMime];
	if (!allowedExts) {
		// No explicit mapping — rely on magic byte verification
		return true;
	}
	return allowedExts.has(ext);
}

/**
 * Validates that the uploaded file's actual bytes match what its declared MIME type claims.
 * Reads only the first 16 bytes from disk — fast and avoids buffering the whole file.
 *
 * @returns true if magic bytes match (or if MIME is in the skip-list), false if mismatch
 */
export async function verifyMagicBytes(filePath: string, declaredMime: string): Promise<boolean> {
	if (SKIP_MAGIC_CHECK.has(declaredMime)) return true;

	const signatures = MAGIC_SIGNATURES[declaredMime];
	if (!signatures) {
		// No signature map for this MIME — allow but log
		return true;
	}

	// SEC-02: Increase header bytes to 512 for better detection of complex formats
	const HEADER_BYTES = 512;
	const fh = await open(filePath, 'r').catch(() => null);
	if (!fh) return false;
	try {
		const buf = Buffer.alloc(HEADER_BYTES);
		const { bytesRead } = await fh.read(buf, 0, HEADER_BYTES, 0);
		const sample = buf.slice(0, bytesRead);

		return signatures.every(([offset, sig]) => {
			if (offset + sig.length > sample.length) return false;
			return sample.slice(offset, offset + sig.length).equals(sig);
		});
	} catch {
		return false;
	} finally {
		await fh.close();
	}
}

/**
 * Compute the SHA-256 checksum of a file on disk.
 * @param filePath Absolute path to the file
 * @returns Hex-encoded SHA-256 hash string
 */
export function calcChecksum(filePath: string): Promise<string> {
	return new Promise((resolve, reject) => {
		const hash = createHash('sha256');
		const stream = createReadStream(filePath);
		stream.on('data', (chunk) => hash.update(chunk));
		stream.on('end', () => resolve(hash.digest('hex')));
		stream.on('error', reject);
	});
}
