import { log } from './log.js';
import { performance } from 'node:perf_hooks';
import { normalizePath } from 'vite';
import { findClosestPkgJsonPath } from 'vitefu';
import { readFileSync } from 'node:fs';
import { dirname } from 'node:path';

/** @type {import('../types/vite-plugin-svelte-stats.d.ts').CollectionOptions} */
const defaultCollectionOptions = {
	// log after 500ms and more than one file processed
	logInProgress: (c, now) => now - c.collectionStart > 500 && c.stats.length > 1,
	// always log results
	logResult: () => true
};

/**
 * @param {number} n
 * @returns
 */
function humanDuration(n) {
	// 99.9ms  0.10s
	return n < 100 ? `${n.toFixed(1)}ms` : `${(n / 1000).toFixed(2)}s`;
}

/**
 * @param {import('../types/vite-plugin-svelte-stats.d.ts').PackageStats[]} pkgStats
 * @returns {string}
 */
function formatPackageStats(pkgStats) {
	const statLines = pkgStats.map((pkgStat) => {
		const duration = pkgStat.duration;
		const avg = duration / pkgStat.files;
		return [pkgStat.pkg, `${pkgStat.files}`, humanDuration(duration), humanDuration(avg)];
	});
	statLines.unshift(['package', 'files', 'time', 'avg']);
	const columnWidths = statLines.reduce(
		(widths, row) => {
			for (let i = 0; i < row.length; i++) {
				const cell = row[i];
				if (widths[i] < cell.length) {
					widths[i] = cell.length;
				}
			}
			return widths;
		},
		statLines[0].map(() => 0)
	);

	const table = statLines
		.map((row) =>
			row
				.map((cell, i) => {
					if (i === 0) {
						return cell.padEnd(columnWidths[i], ' ');
					} else {
						return cell.padStart(columnWidths[i], ' ');
					}
				})
				.join('\t')
		)
		.join('\n');
	return table;
}

/**
 * @class
 */
export class VitePluginSvelteStats {
	/** @type {PackageInfo[]} */
	#packageInfos = [];

	/** @type {import('../types/vite-plugin-svelte-stats.d.ts').StatCollection[]} */
	#collections = [];

	/**
	 * @param {string} name
	 * @param {Partial<import('../types/vite-plugin-svelte-stats.d.ts').CollectionOptions>} [opts]
	 * @returns {import('../types/vite-plugin-svelte-stats.d.ts').StatCollection}
	 */
	startCollection(name, opts) {
		const options = {
			...defaultCollectionOptions,
			...opts
		};
		/** @type {import('../types/vite-plugin-svelte-stats.d.ts').Stat[]} */
		const stats = [];
		const collectionStart = performance.now();

		const _this = this;
		let hasLoggedProgress = false;
		/** @type {import('../types/vite-plugin-svelte-stats.d.ts').StatCollection} */
		const collection = {
			name,
			options,
			stats,
			collectionStart,
			finished: false,
			start(file) {
				if (collection.finished) {
					throw new Error('called after finish() has been used');
				}
				file = normalizePath(file);
				const start = performance.now();
				/** @type {import('../types/vite-plugin-svelte-stats.d.ts').Stat} */
				const stat = { file, start, end: start };
				return () => {
					const now = performance.now();
					stat.end = now;
					stats.push(stat);
					if (!hasLoggedProgress && options.logInProgress(collection, now)) {
						hasLoggedProgress = true;
						log.debug(`${name} in progress ...`, undefined, 'stats');
					}
				};
			},
			async finish() {
				await _this.#finish(collection);
			}
		};
		_this.#collections.push(collection);
		return collection;
	}

	async finishAll() {
		await Promise.all(this.#collections.map((c) => c.finish()));
	}

	/**
	 * @param {import('../types/vite-plugin-svelte-stats.d.ts').StatCollection} collection
	 */
	async #finish(collection) {
		try {
			collection.finished = true;
			const now = performance.now();
			collection.duration = now - collection.collectionStart;
			const logResult = collection.options.logResult(collection);
			if (logResult) {
				await this.#aggregateStatsResult(collection);
				log.debug(
					`${collection.name} done.\n${formatPackageStats(
						/** @type {import('../types/vite-plugin-svelte-stats.d.ts').PackageStats[]}*/ (
							collection.packageStats
						)
					)}`,
					undefined,
					'stats'
				);
			}
			// cut some ties to free it for garbage collection
			const index = this.#collections.indexOf(collection);
			this.#collections.splice(index, 1);
			collection.stats.length = 0;
			collection.stats = [];
			if (collection.packageStats) {
				collection.packageStats.length = 0;
				collection.packageStats = [];
			}
			collection.start = () => () => {};
			collection.finish = () => {};
		} catch (e) {
			// this should not happen, but stats taking also should not break the process
			log.debug.once(`failed to finish stats for ${collection.name}\n`, e, 'stats');
		}
	}

	/**
	 * @param {import('../types/vite-plugin-svelte-stats.d.ts').StatCollection} collection
	 */
	async #aggregateStatsResult(collection) {
		const stats = collection.stats;
		for (const stat of stats) {
			stat.pkg = (await this.#getPackageInfo(stat.file)).name;
		}

		// group stats
		/** @type {Record<string, import('../types/vite-plugin-svelte-stats.d.ts').PackageStats>} */
		const grouped = {};
		stats.forEach((stat) => {
			const pkg = /** @type {string} */ (stat.pkg);
			let group = grouped[pkg];
			if (!group) {
				group = grouped[pkg] = {
					files: 0,
					duration: 0,
					pkg
				};
			}
			group.files += 1;
			group.duration += stat.end - stat.start;
		});

		const groups = Object.values(grouped);
		groups.sort((a, b) => b.duration - a.duration);
		collection.packageStats = groups;
	}
	/**
	 * @param {string} file
	 * @returns {Promise<PackageInfo>}
	 */
	async #getPackageInfo(file) {
		let info = this.#packageInfos.find((pi) => file.startsWith(pi.path));
		if (!info) {
			info = await findPackageInfo(file);
			this.#packageInfos.push(info);
		}
		return info;
	}
}

/**
 * @typedef {{
 * 	name: string;
 * 	version: string;
 * 	svelte?: string;
 * 	path: string;
 * }} PackageInfo
 */

/**
 * utility to get some info from the closest package.json with a "name" set
 *
 * @param {string} file to find info for
 * @returns {Promise<PackageInfo>}
 */
async function findPackageInfo(file) {
	/** @type {PackageInfo} */
	const info = {
		name: '$unknown',
		version: '0.0.0-unknown',
		path: '$unknown'
	};
	let path = await findClosestPkgJsonPath(file, (pkgPath) => {
		const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
		if (pkg.name != null) {
			info.name = pkg.name;
			if (pkg.version != null) {
				info.version = pkg.version;
			}
			info.svelte = pkg.svelte;
			return true;
		}
		return false;
	});
	// return normalized path with appended '/' so .startsWith works for future file checks
	path = normalizePath(dirname(path ?? file)) + '/';
	info.path = path;
	return info;
}
