#!/usr/bin/env node

/**
 * Check and optionally sync local SVG icons with matching files from simple-icons.
 *
 * Default behaviour:
 * - scans `assets/ananke/socials/`
 * - only checks `.svg` files that already exist locally
 * - compares them with:
 *   https://raw.githubusercontent.com/simple-icons/simple-icons/develop/icons/
 * - dry-run is the default
 * - use `--apply` to overwrite changed files
 * - use `--delete` to remove local files that do not exist upstream
 * - use `--all` to sync against all upstream SVG files in the icons directory
 *
 * Requirements:
 * - Node.js 22+
 *
 * Examples:
 * - node src/scripts/update-social-icons.ts
 * - node src/scripts/update-social-icons.ts --only-changed
 * - node src/scripts/update-social-icons.ts --apply
 * - node src/scripts/update-social-icons.ts --apply --delete
 * - node src/scripts/update-social-icons.ts --all
 * - node src/scripts/update-social-icons.ts --all --apply
 * - node src/scripts/update-social-icons.ts --all --apply --delete
 * - node src/scripts/update-social-icons.ts --json
 */

import { promises as fs } from "node:fs";
import path from "node:path";
import process from "node:process";

type CliOptions = {
	dir: string;
	branch: string;
	apply: boolean;
	deleteMissing: boolean;
	onlyChanged: boolean;
	json: boolean;
	verbose: boolean;
	all: boolean;
	concurrency: number;
	help: boolean;
};

type FileResultStatus =
	| "same"
	| "changed"
	| "missing"
	| "would-create"
	| "created"
	| "would-delete"
	| "deleted"
	| "updated"
	| "error";

type FileResult = {
	fileName: string;
	localPath: string;
	remoteUrl: string;
	status: FileResultStatus;
	message: string;
};

type Summary = {
	same: number;
	changed: number;
	missing: number;
	wouldCreate: number;
	created: number;
	wouldDelete: number;
	deleted: number;
	updated: number;
	errors: number;
};

type JsonOutput = {
	options: {
		dir: string;
		branch: string;
		apply: boolean;
		deleteMissing: boolean;
		onlyChanged: boolean;
		json: boolean;
		verbose: boolean;
		all: boolean;
		concurrency: number;
	};
	results: FileResult[];
	summary: Summary;
};

type ProcessingTask =
	| {
			kind: "upstream";
			fileName: string;
	  }
	| {
			kind: "local-only";
			fileName: string;
	  };

const DEFAULT_DIRECTORY = "assets/ananke/socials";
const DEFAULT_BRANCH = "develop";
const SIMPLE_ICONS_OWNER = "simple-icons";
const SIMPLE_ICONS_REPO = "simple-icons";
const SIMPLE_ICONS_RAW_BASE =
	"https://raw.githubusercontent.com/simple-icons/simple-icons";

const EXIT_SUCCESS = 0;
const EXIT_FAILURE = 1;

/**
 * Print CLI help.
 */
function printHelp(): void {
	const scriptName = path.basename(process.argv[1] ?? "update-social-icons.ts");

	console.log(
		`
Usage:
  ${scriptName} [options]

Options:
  --dir <path>         Directory containing local SVG icons
                       Default: ${DEFAULT_DIRECTORY}

  --branch <name>      Branch of simple-icons to read from
                       Default: ${DEFAULT_BRANCH}

  --apply              Replace changed local files with upstream versions
                       Default: disabled

  --delete             Delete local files that do not exist upstream
                       In dry-run mode this only reports "would delete"

  --all                Sync against all upstream SVG icons in the repo folder
                       This creates missing local files and can delete extra
                       local files when combined with --delete

  --only-changed       Show only changed, updated, missing, would-create,
                       created, would-delete, deleted, and error results

  --json               Print JSON output instead of human-readable text

  --verbose            Print additional information

  --concurrency <n>    Number of files to process in parallel
                       Default: 8

  --help               Show this help

Behaviour:
  - Dry-run is the default.
  - Without --all, only existing local .svg files are checked.
  - With --all, all upstream .svg files in simple-icons/icons/ are checked.
  - Each file is matched by filename against:
    ${SIMPLE_ICONS_RAW_BASE}/<branch>/icons/<filename>

Output states:
  * same          local file matches upstream
  * changed       upstream file differs from local file
  * missing       no matching upstream file found
  * would-create  upstream file missing locally and would be created
  * created       upstream file missing locally and was created
  * would-delete  local file missing upstream and would be deleted
  * deleted       local file missing upstream and was deleted
  * updated       local file was replaced because --apply was used
  * error         processing failed
`.trim(),
	);
}

/**
 * Parse CLI arguments into a typed options object.
 *
 * @param argv Process arguments excluding node/script prefix.
 * @returns Parsed CLI options.
 */
function parseArgs(argv: string[]): CliOptions {
	const options: CliOptions = {
		dir: DEFAULT_DIRECTORY,
		branch: DEFAULT_BRANCH,
		apply: false,
		deleteMissing: false,
		onlyChanged: false,
		json: false,
		verbose: false,
		all: false,
		concurrency: 8,
		help: false,
	};

	for (let index = 0; index < argv.length; index += 1) {
		const arg = argv[index];
		if (arg === undefined) {
			continue;
		}

		if (arg === "--help") {
			options.help = true;
			continue;
		}

		if (arg === "--apply") {
			options.apply = true;
			continue;
		}

		if (arg === "--delete") {
			options.deleteMissing = true;
			continue;
		}

		if (arg === "--only-changed") {
			options.onlyChanged = true;
			continue;
		}

		if (arg === "--json") {
			options.json = true;
			continue;
		}

		if (arg === "--verbose") {
			options.verbose = true;
			continue;
		}

		if (arg === "--all") {
			options.all = true;
			continue;
		}

		if (arg === "--dir") {
			const value = argv[index + 1];
			if (!value) {
				throw new Error("Missing value for --dir");
			}
			options.dir = value;
			index += 1;
			continue;
		}

		if (arg.startsWith("--dir=")) {
			options.dir = arg.slice("--dir=".length);
			continue;
		}

		if (arg === "--branch") {
			const value = argv[index + 1];
			if (!value) {
				throw new Error("Missing value for --branch");
			}
			options.branch = value;
			index += 1;
			continue;
		}

		if (arg.startsWith("--branch=")) {
			options.branch = arg.slice("--branch=".length);
			continue;
		}

		if (arg === "--concurrency") {
			const value = argv[index + 1];
			if (!value) {
				throw new Error("Missing value for --concurrency");
			}

			const parsed = Number.parseInt(value, 10);
			if (!Number.isInteger(parsed) || parsed < 1) {
				throw new Error("--concurrency must be a positive integer");
			}

			options.concurrency = parsed;
			index += 1;
			continue;
		}

		if (arg.startsWith("--concurrency=")) {
			const value = arg.slice("--concurrency=".length);
			const parsed = Number.parseInt(value, 10);
			if (!Number.isInteger(parsed) || parsed < 1) {
				throw new Error("--concurrency must be a positive integer");
			}

			options.concurrency = parsed;
			continue;
		}

		throw new Error(`Unknown argument: ${arg}`);
	}

	return options;
}

/**
 * Build the upstream raw URL for a given icon filename.
 *
 * @param branch Branch name in simple-icons.
 * @param fileName SVG filename.
 * @returns Raw GitHub URL.
 */
function buildRemoteUrl(branch: string, fileName: string): string {
	return `${SIMPLE_ICONS_RAW_BASE}/${encodeURIComponent(branch)}/icons/${encodeURIComponent(fileName)}`;
}

/**
 * Read SVG filenames from the local target directory.
 *
 * @param directory Directory to scan.
 * @returns Sorted list of SVG filenames.
 */
async function getLocalSvgFiles(directory: string): Promise<string[]> {
	const entries = await fs.readdir(directory, { withFileTypes: true });

	return entries
		.filter((entry) => entry.isFile())
		.map((entry) => entry.name)
		.filter((fileName) => fileName.toLowerCase().endsWith(".svg"))
		.sort((left, right) => left.localeCompare(right));
}

/**
 * Read upstream SVG filenames from the GitHub contents API.
 *
 * @param branch Branch name in simple-icons.
 * @returns Sorted list of upstream SVG filenames.
 */
async function getUpstreamSvgFiles(branch: string): Promise<string[]> {
	const apiUrl = new URL(
		`https://api.github.com/repos/${encodeURIComponent(SIMPLE_ICONS_OWNER)}/${encodeURIComponent(SIMPLE_ICONS_REPO)}/contents/icons`,
	);
	apiUrl.searchParams.set("ref", branch);

	const response = await fetch(apiUrl.toString(), {
		method: "GET",
		headers: {
			Accept: "application/vnd.github+json",
			"User-Agent": "davidsneighbour-social-icon-updater",
		},
	});

	if (!response.ok) {
		throw new Error(
			`Failed to retrieve upstream icon listing with HTTP ${response.status}`,
		);
	}

	const data: unknown = await response.json();

	if (!Array.isArray(data)) {
		throw new Error(
			"Unexpected response while retrieving upstream icon listing",
		);
	}

	const fileNames = data
		.flatMap((entry: unknown) => {
			if (
				typeof entry === "object" &&
				entry !== null &&
				"type" in entry &&
				"name" in entry &&
				entry.type === "file" &&
				typeof entry.name === "string" &&
				entry.name.toLowerCase().endsWith(".svg")
			) {
				return [entry.name];
			}

			return [];
		})
		.sort((left, right) => left.localeCompare(right));

	return fileNames;
}

/**
 * Fetch the upstream SVG file content.
 *
 * Returns null when the upstream file does not exist.
 *
 * @param remoteUrl Raw GitHub URL.
 * @returns SVG content or null if not found.
 */
async function fetchRemoteSvg(remoteUrl: string): Promise<string | null> {
	const response = await fetch(remoteUrl, {
		method: "GET",
		headers: {
			Accept: "image/svg+xml,text/plain;q=0.9,*/*;q=0.8",
			"User-Agent": "davidsneighbour-social-icon-updater",
		},
	});

	if (response.status === 404) {
		return null;
	}

	if (!response.ok) {
		throw new Error(
			`Request failed with HTTP ${response.status} for ${remoteUrl}`,
		);
	}

	return await response.text();
}

/**
 * Process one upstream-backed file.
 *
 * - If the upstream file is missing locally:
 *   - dry-run => would-create
 *   - apply   => created
 * - If the local file exists and differs:
 *   - dry-run => changed
 *   - apply   => updated
 * - If the local file exists and matches:
 *   - same
 *
 * @param directory Local directory.
 * @param fileName File name to process.
 * @param branch Upstream branch.
 * @param apply Whether changed or missing local files should be written.
 * @returns Processing result.
 */
async function processUpstreamFile(
	directory: string,
	fileName: string,
	branch: string,
	apply: boolean,
): Promise<FileResult> {
	const localPath = path.join(directory, fileName);
	const remoteUrl = buildRemoteUrl(branch, fileName);

	try {
		const remoteContent = await fetchRemoteSvg(remoteUrl);

		if (remoteContent === null) {
			return {
				fileName,
				localPath,
				remoteUrl,
				status: "missing",
				message: "No matching upstream file found",
			};
		}

		const localStats = await fs.stat(localPath).catch(() => null);

		if (!localStats) {
			if (!apply) {
				return {
					fileName,
					localPath,
					remoteUrl,
					status: "would-create",
					message: "Upstream file exists and would be created locally",
				};
			}

			await fs.writeFile(localPath, remoteContent, "utf8");

			return {
				fileName,
				localPath,
				remoteUrl,
				status: "created",
				message: "Upstream file existed and was created locally",
			};
		}

		const localContent = await fs.readFile(localPath, "utf8");

		if (localContent === remoteContent) {
			return {
				fileName,
				localPath,
				remoteUrl,
				status: "same",
				message: "Local file matches upstream",
			};
		}

		if (!apply) {
			return {
				fileName,
				localPath,
				remoteUrl,
				status: "changed",
				message: "Upstream file differs from local file",
			};
		}

		await fs.writeFile(localPath, remoteContent, "utf8");

		return {
			fileName,
			localPath,
			remoteUrl,
			status: "updated",
			message: "Local file replaced with upstream version",
		};
	} catch (error: unknown) {
		const message =
			error instanceof Error
				? error.message
				: "Unknown error while processing file";

		return {
			fileName,
			localPath,
			remoteUrl,
			status: "error",
			message,
		};
	}
}

/**
 * Process one local-only file for delete handling.
 *
 * @param directory Local directory.
 * @param fileName File name to process.
 * @param branch Upstream branch.
 * @param apply Whether deletions should be applied.
 * @param deleteMissing Whether missing upstream files should be deleted locally.
 * @returns Processing result.
 */
async function processLocalOnlyFile(
	directory: string,
	fileName: string,
	branch: string,
	apply: boolean,
	deleteMissing: boolean,
): Promise<FileResult> {
	const localPath = path.join(directory, fileName);
	const remoteUrl = buildRemoteUrl(branch, fileName);

	try {
		if (!deleteMissing) {
			return {
				fileName,
				localPath,
				remoteUrl,
				status: "missing",
				message: "No matching upstream file found",
			};
		}

		if (!apply) {
			return {
				fileName,
				localPath,
				remoteUrl,
				status: "would-delete",
				message: "No matching upstream file found; local file would be deleted",
			};
		}

		await fs.unlink(localPath);

		return {
			fileName,
			localPath,
			remoteUrl,
			status: "deleted",
			message: "No matching upstream file found; local file deleted",
		};
	} catch (error: unknown) {
		const message =
			error instanceof Error
				? error.message
				: "Unknown error while processing file";

		return {
			fileName,
			localPath,
			remoteUrl,
			status: "error",
			message,
		};
	}
}

/**
 * Determine whether a result should be printed in text mode.
 *
 * @param status Result status.
 * @param onlyChanged Whether same results should be hidden.
 * @returns True if the result should be shown.
 */
function shouldPrintResult(
	status: FileResultStatus,
	onlyChanged: boolean,
): boolean {
	if (!onlyChanged) {
		return true;
	}

	return status !== "same";
}

/**
 * Print a single result line.
 *
 * @param result File result.
 * @param verbose Whether to include detail lines.
 */
function printResult(result: FileResult, verbose: boolean): void {
	const prefixByStatus: Record<FileResultStatus, string> = {
		same: "SAME",
		changed: "CHANGED",
		missing: "MISS",
		"would-create": "WOULD-CREATE",
		created: "CREATED",
		"would-delete": "WOULD-DELETE",
		deleted: "DELETED",
		updated: "UPDATED",
		error: "ERR",
	};

	console.log(
		`${prefixByStatus[result.status]} ${result.fileName} - ${result.message}`,
	);

	if (verbose) {
		console.log(`  local:  ${result.localPath}`);
		console.log(`  remote: ${result.remoteUrl}`);
	}
}

/**
 * Build a summary object.
 *
 * @param results All results.
 * @returns Summary counts.
 */
function buildSummary(results: FileResult[]): Summary {
	return {
		same: results.filter((entry) => entry.status === "same").length,
		changed: results.filter((entry) => entry.status === "changed").length,
		missing: results.filter((entry) => entry.status === "missing").length,
		wouldCreate: results.filter((entry) => entry.status === "would-create")
			.length,
		created: results.filter((entry) => entry.status === "created").length,
		wouldDelete: results.filter((entry) => entry.status === "would-delete")
			.length,
		deleted: results.filter((entry) => entry.status === "deleted").length,
		updated: results.filter((entry) => entry.status === "updated").length,
		errors: results.filter((entry) => entry.status === "error").length,
	};
}

/**
 * Print a summary section.
 *
 * @param summary Summary counts.
 */
function printSummary(summary: Summary): void {
	console.log("");
	console.log("Summary");
	console.log(`  same:          ${summary.same}`);
	console.log(`  changed:       ${summary.changed}`);
	console.log(`  updated:       ${summary.updated}`);
	console.log(`  missing:       ${summary.missing}`);
	console.log(`  would-create:  ${summary.wouldCreate}`);
	console.log(`  created:       ${summary.created}`);
	console.log(`  would-delete:  ${summary.wouldDelete}`);
	console.log(`  deleted:       ${summary.deleted}`);
	console.log(`  errors:        ${summary.errors}`);
}

/**
 * Render a one-line progress indicator.
 *
 * @param processed Number of processed tasks.
 * @param total Total number of tasks.
 * @param active Number of currently active tasks.
 */
function renderProgress(
	processed: number,
	total: number,
	active: number,
): void {
	process.stdout.write(
		`\rProcessing ${processed}/${total} (active: ${active})`,
	);
}

/**
 * Clear the progress line from the terminal.
 */
function clearProgressLine(): void {
	process.stdout.write("\r\x1b[K");
}

/**
 * Run tasks with a bounded concurrency limit.
 *
 * @param params Runner parameters.
 * @returns Results in task order.
 */
async function runTasksWithConcurrency<TInput, TResult>(params: {
	items: TInput[];
	concurrency: number;
	worker: (item: TInput) => Promise<TResult>;
	onResult?: (
		result: TResult,
		processed: number,
		total: number,
		active: number,
	) => void;
}): Promise<TResult[]> {
	const { items, concurrency, worker, onResult } = params;

	const results = new Array<TResult>(items.length);
	let nextIndex = 0;
	let processed = 0;
	let active = 0;

	async function runWorker(): Promise<void> {
		while (true) {
			const currentIndex = nextIndex;
			if (currentIndex >= items.length) {
				return;
			}

			nextIndex += 1;
			active += 1;

			try {
				// Index is bounds-checked above; TInput itself may include undefined.
				const result = await worker(items[currentIndex] as TInput);
				results[currentIndex] = result;
			} finally {
				active -= 1;
				processed += 1;

				const result = results[currentIndex];
				if (result !== undefined && onResult) {
					onResult(result, processed, items.length, active);
				}
			}
		}
	}

	const workerCount = Math.min(concurrency, items.length);
	await Promise.all(
		Array.from({ length: workerCount }, async () => {
			await runWorker();
		}),
	);

	return results;
}

/**
 * Main program entry point.
 */
async function main(): Promise<void> {
	const options = parseArgs(process.argv.slice(2));

	if (options.help) {
		printHelp();
		process.exit(EXIT_SUCCESS);
	}

	const targetDirectory = path.resolve(options.dir);

	const stats = await fs.stat(targetDirectory).catch(() => null);
	if (!stats || !stats.isDirectory()) {
		throw new Error(
			`Directory does not exist or is not a directory: ${targetDirectory}`,
		);
	}

	const localFiles = await getLocalSvgFiles(targetDirectory);

	let upstreamFiles: string[] = [];
	if (options.all) {
		upstreamFiles = await getUpstreamSvgFiles(options.branch);
	}

	const emptySummary: Summary = {
		same: 0,
		changed: 0,
		missing: 0,
		wouldCreate: 0,
		created: 0,
		wouldDelete: 0,
		deleted: 0,
		updated: 0,
		errors: 0,
	};

	const tasks: ProcessingTask[] = [];

	if (options.all) {
		const upstreamSet = new Set(upstreamFiles);

		for (const fileName of upstreamFiles) {
			tasks.push({
				kind: "upstream",
				fileName,
			});
		}

		for (const fileName of localFiles) {
			if (!upstreamSet.has(fileName)) {
				tasks.push({
					kind: "local-only",
					fileName,
				});
			}
		}
	} else {
		if (localFiles.length === 0) {
			if (options.json) {
				const emptyOutput: JsonOutput = {
					options: {
						dir: targetDirectory,
						branch: options.branch,
						apply: options.apply,
						deleteMissing: options.deleteMissing,
						onlyChanged: options.onlyChanged,
						json: options.json,
						verbose: options.verbose,
						all: options.all,
						concurrency: options.concurrency,
					},
					results: [],
					summary: emptySummary,
				};
				console.log(JSON.stringify(emptyOutput, null, 2));
			} else {
				console.log(`No .svg files found in ${targetDirectory}`);
			}

			process.exit(EXIT_SUCCESS);
		}

		for (const fileName of localFiles) {
			tasks.push({
				kind: "upstream",
				fileName,
			});
		}
	}

	if (!options.json) {
		if (options.all) {
			console.log(
				`Syncing against all upstream SVG file(s) from simple-icons/icons/ into ${targetDirectory}`,
			);
			console.log(`Found ${upstreamFiles.length} upstream SVG file(s).`);
			console.log(`Found ${localFiles.length} local SVG file(s).`);
		} else {
			console.log(
				`Checking ${localFiles.length} local SVG file(s) in ${targetDirectory}`,
			);
		}

		console.log(`Using simple-icons branch: ${options.branch}`);
		console.log(`Concurrency: ${options.concurrency}`);
		console.log(
			options.apply
				? "Apply mode enabled. Changed files will be written."
				: "Dry run mode enabled. No files will be written.",
		);

		if (options.deleteMissing) {
			console.log(
				options.apply
					? "Delete mode enabled. Local files missing upstream will be deleted."
					: "Delete mode enabled. Local files missing upstream will be reported as would-delete.",
			);
		}

		if (options.onlyChanged) {
			console.log("Only changed results will be shown.");
		}

		console.log("");
	}

	const results = await runTasksWithConcurrency<ProcessingTask, FileResult>({
		items: tasks,
		concurrency: options.concurrency,
		worker: async (task): Promise<FileResult> => {
			if (task.kind === "upstream") {
				const result = await processUpstreamFile(
					targetDirectory,
					task.fileName,
					options.branch,
					options.apply,
				);

				if (result.status === "missing" && !options.all) {
					return await processLocalOnlyFile(
						targetDirectory,
						task.fileName,
						options.branch,
						options.apply,
						options.deleteMissing,
					);
				}

				return result;
			}

			return await processLocalOnlyFile(
				targetDirectory,
				task.fileName,
				options.branch,
				options.apply,
				options.deleteMissing,
			);
		},
		onResult: (result, processed, total, active): void => {
			if (!options.json) {
				renderProgress(processed, total, active);

				if (shouldPrintResult(result.status, options.onlyChanged)) {
					clearProgressLine();
					printResult(result, options.verbose);
					renderProgress(processed, total, active);
				}
			}
		},
	});

	if (!options.json) {
		clearProgressLine();
	}

	const summary = buildSummary(results);

	if (options.json) {
		const output: JsonOutput = {
			options: {
				dir: targetDirectory,
				branch: options.branch,
				apply: options.apply,
				deleteMissing: options.deleteMissing,
				onlyChanged: options.onlyChanged,
				json: options.json,
				verbose: options.verbose,
				all: options.all,
				concurrency: options.concurrency,
			},
			results: options.onlyChanged
				? results.filter((result) => shouldPrintResult(result.status, true))
				: results,
			summary,
		};

		console.log(JSON.stringify(output, null, 2));
	} else {
		printSummary(summary);
	}

	const hasErrors = results.some((entry) => entry.status === "error");
	process.exit(hasErrors ? EXIT_FAILURE : EXIT_SUCCESS);
}

main().catch((error: unknown) => {
	const message =
		error instanceof Error ? error.message : "Unknown fatal error";
	console.error(`Fatal error: ${message}`);
	process.exit(EXIT_FAILURE);
});
