import fs from "node:fs";
import path from "node:path";
import type { QueuedProcess, UploadFileParams } from "@lokalise/node-api";
import type {
	CollectFileParams,
	FileUploadError,
	PartialUploadFileParams,
	ProcessedFile,
	ProcessUploadFileParams,
	QueuedUploadProcessesWithErrors,
	UploadTranslationParams,
} from "../interfaces/index.js";
import { LokaliseFileExchange } from "./LokaliseFileExchange.js";

/**
 * Handles uploading translation files to Lokalise.
 */
export class LokaliseUpload extends LokaliseFileExchange {
	private static readonly defaultPollingParams = {
		pollStatuses: false,
		pollInitialWaitTime: 1000,
		pollMaximumWaitTime: 120_000,
	};

	/**
	 * Collects files, uploads them to Lokalise, and optionally polls for process completion, returning both processes and errors.
	 *
	 * @param {UploadTranslationParams} uploadTranslationParams - Parameters for collecting and uploading files.
	 * @returns {Promise<{ processes: QueuedProcess[]; errors: FileUploadError[] }>} A promise resolving with successful processes and upload errors.
	 */
	async uploadTranslations({
		uploadFileParams,
		collectFileParams,
		processUploadFileParams,
	}: UploadTranslationParams = {}): Promise<QueuedUploadProcessesWithErrors> {
		this.logMsg("debug", "Uploading translations to Lokalise...");

		const { pollStatuses, pollInitialWaitTime, pollMaximumWaitTime } = {
			...LokaliseUpload.defaultPollingParams,
			...processUploadFileParams,
		};

		this.logMsg("debug", "Collecting files to upload...");
		const collectedFiles = await this.collectFiles(collectFileParams);
		this.logMsg("debug", "Collected files:", collectedFiles);

		this.logMsg("debug", "Performing parallel upload...");
		const { processes, errors } = await this.parallelUpload(
			collectedFiles,
			uploadFileParams,
			processUploadFileParams,
		);

		let completedProcesses = processes;
		this.logMsg(
			"debug",
			"File uploading queued! IDs:",
			completedProcesses.map((p) => p.process_id),
		);

		if (pollStatuses) {
			this.logMsg("debug", "Polling queued processes...");

			completedProcesses = await this.pollProcesses(
				processes,
				pollInitialWaitTime,
				pollMaximumWaitTime,
			);

			this.logMsg("debug", "Polling completed!");
		}

		this.logMsg("debug", "Upload successful!");

		return { processes: completedProcesses, errors };
	}

	/**
	 * Collects files from the filesystem based on the given parameters.
	 *
	 * @param {CollectFileParams} collectFileParams - Parameters for file collection, including directories, extensions, and patterns.
	 * @returns {Promise<string[]>} A promise resolving with the list of collected file paths.
	 */
	protected async collectFiles({
		inputDirs = ["./locales"],
		extensions = [".*"],
		excludePatterns = [],
		recursive = true,
		fileNamePattern = ".*",
	}: CollectFileParams = {}): Promise<string[]> {
		const queue = this.makeQueue(inputDirs);
		const normalizedExtensions = this.normalizeExtensions(extensions);
		const fileNameRegex = this.makeFilenameRegexp(fileNamePattern);
		const excludeRegexes = this.makeExcludeRegExes(excludePatterns);

		const files = await this.processCollectionQueue(
			queue,
			normalizedExtensions,
			fileNameRegex,
			excludeRegexes,
			recursive,
		);

		return files.sort();
	}

	/**
	 * Uploads a single file to Lokalise.
	 *
	 * @param {UploadFileParams} uploadParams - Parameters for uploading the file.
	 * @returns {Promise<QueuedProcess>} A promise resolving with the upload process details.
	 */
	protected async uploadSingleFile(
		uploadParams: UploadFileParams,
	): Promise<QueuedProcess> {
		return this.withExponentialBackoff(() =>
			this.apiClient.files().upload(this.projectId, uploadParams),
		);
	}

	/**
	 * Processes a file to prepare it for upload, converting it to base64 and extracting its language code.
	 *
	 * @param file - The absolute path to the file.
	 * @param projectRoot - The root directory of the project.
	 * @param processParams - Optional processing settings including inferers.
	 * @returns A promise resolving with the processed file details, including base64 content, relative path, and language code.
	 */
	protected async processFile(
		file: string,
		projectRoot: string,
		processParams?: ProcessUploadFileParams,
	): Promise<ProcessedFile> {
		const relativePath = await this.inferRelativePath(
			file,
			projectRoot,
			processParams,
		);

		const languageCode = await this.inferLanguageCode(
			file,
			relativePath,
			processParams,
		);

		const base64Content = await this.readFileAsBase64(file);

		return {
			data: base64Content,
			filename: relativePath,
			lang_iso: languageCode,
		};
	}

	/**
	 * Infers the relative path for an uploaded file.
	 *
	 * Tries a custom `filenameInferer` first; if it fails or returns empty/whitespace,
	 * falls back to a POSIX-style relative path based on the project root.
	 *
	 * @param file - Absolute path to the source file.
	 * @param projectRoot - Root directory of the project.
	 * @param processParams - Optional processing settings including filename inferer.
	 * @returns A promise resolving with the inferred relative path.
	 */
	private async inferRelativePath(
		file: string,
		projectRoot: string,
		processParams?: ProcessUploadFileParams,
	): Promise<string> {
		try {
			const fromInferer = processParams?.filenameInferer
				? await processParams.filenameInferer(file)
				: "";

			if (!fromInferer.trim()) {
				throw new Error("Invalid filename: empty or only whitespace");
			}

			return fromInferer;
		} catch {
			// Fallback: derive a POSIX relative path from project root
			return path.posix.relative(
				this.toPosixPath(projectRoot),
				this.toPosixPath(file),
			);
		}
	}

	/**
	 * Infers the language code for an uploaded file.
	 *
	 * Tries a custom `languageInferer` first; if it fails or returns empty/whitespace,
	 * falls back to extracting the language code from the filename before the last extension.
	 *
	 * Example: "en.default.json" → "default"
	 *
	 * @param file - Absolute path to the source file.
	 * @param relativePath - Effective relative path of the file (used for fallback parsing).
	 * @param processParams - Optional processing settings including language inferer.
	 * @returns A promise resolving with the inferred language code.
	 */
	private async inferLanguageCode(
		file: string,
		relativePath: string,
		processParams?: ProcessUploadFileParams,
	): Promise<string> {
		try {
			const fromInferer = processParams?.languageInferer
				? await processParams.languageInferer(file)
				: "";

			if (!fromInferer.trim()) {
				throw new Error("Invalid language code: empty or only whitespace");
			}

			return fromInferer;
		} catch {
			// Fallback: derive language code from the basename of the relative path
			const baseName = path.basename(relativePath);
			return baseName.split(".").slice(-2, -1)[0] ?? "unknown";
		}
	}

	/**
	 * Reads a file from disk and returns its content encoded as base64.
	 *
	 * @param file - Absolute path to the source file.
	 * @returns A promise resolving with the file content encoded in base64.
	 */
	private async readFileAsBase64(file: string): Promise<string> {
		const fileContent = await fs.promises.readFile(file);
		return fileContent.toString("base64");
	}

	/**
	 * Uploads files in parallel with a limit on the number of concurrent uploads.
	 *
	 * @param {string[]} files - List of file paths to upload.
	 * @param {Partial<UploadFileParams>} baseUploadFileParams - Base parameters for uploads.
	 * @param {ProcessUploadFileParams} [processParams] - Optional processing settings including inferers.
	 * @returns {Promise<{ processes: QueuedProcess[]; errors: FileUploadError[] }>} A promise resolving with successful processes and upload errors.
	 */
	private async parallelUpload(
		files: string[],
		baseUploadFileParams: PartialUploadFileParams = {},
		processParams?: ProcessUploadFileParams,
	): Promise<QueuedUploadProcessesWithErrors> {
		const projectRoot = process.cwd();
		const queuedProcesses: QueuedProcess[] = [];
		const errors: FileUploadError[] = [];

		await this.runWithConcurrencyLimit(
			files,
			LokaliseUpload.maxConcurrentProcesses,
			async (file) => {
				try {
					const processedFileParams = await this.processFile(
						file,
						projectRoot,
						processParams,
					);
					const queued = await this.uploadSingleFile({
						...baseUploadFileParams,
						...processedFileParams,
					});
					queuedProcesses.push(queued);
				} catch (error) {
					errors.push({ file, error });
				}
			},
		);

		return { processes: queuedProcesses, errors };
	}

	/**
	 * Normalizes an array of file extensions by ensuring each starts with a dot and is lowercase.
	 *
	 * @param extensions - The list of file extensions to normalize.
	 * @returns A new array with normalized file extensions.
	 */
	private normalizeExtensions(extensions: string[]): string[] {
		return extensions.map((ext) =>
			(ext.startsWith(".") ? ext : `.${ext}`).toLowerCase(),
		);
	}

	/**
	 * Determines whether a file should be collected based on its extension and name pattern.
	 *
	 * @param entry - The directory entry to evaluate.
	 * @param normalizedExtensions - List of allowed file extensions.
	 * @param fileNameRegex - Regular expression to match valid filenames.
	 * @returns `true` if the file matches both extension and name pattern, otherwise `false`.
	 */
	private shouldCollectFile(
		entry: fs.Dirent,
		normalizedExtensions: string[],
		fileNameRegex: RegExp,
	): boolean {
		const fileExt = path.extname(entry.name).toLowerCase();
		const matchesExtension =
			normalizedExtensions.includes(".*") ||
			normalizedExtensions.includes(fileExt);
		const matchesFilenamePattern = fileNameRegex.test(entry.name);

		return matchesExtension && matchesFilenamePattern;
	}

	/**
	 * Creates a regular expression from a given pattern string or RegExp.
	 *
	 * @param fileNamePattern - The filename pattern to convert into a RegExp.
	 * @returns A valid RegExp object.
	 * @throws {Error} If the pattern string is invalid and cannot be compiled.
	 */
	private makeFilenameRegexp(fileNamePattern: string | RegExp): RegExp {
		try {
			return new RegExp(fileNamePattern);
		} catch {
			throw new Error(`Invalid fileNamePattern: ${fileNamePattern}`);
		}
	}

	/**
	 * Converts an array of exclude patterns into an array of RegExp objects.
	 *
	 * @param excludePatterns - An array of strings or regular expressions to exclude.
	 * @returns An array of compiled RegExp objects.
	 * @throws {Error} If any pattern is invalid and cannot be compiled.
	 */
	private makeExcludeRegExes(excludePatterns: string[] | RegExp[]): RegExp[] {
		if (excludePatterns.length === 0) {
			return [];
		}
		try {
			return excludePatterns.map((pattern) => new RegExp(pattern));
		} catch (err) {
			const msg = err instanceof Error ? err.message : String(err);
			throw new Error(`Invalid excludePatterns: ${msg}`);
		}
	}

	/**
	 * Safely reads the contents of a directory, returning an empty array if access fails.
	 *
	 * Logs a warning if the directory cannot be read (e.g. due to permissions or non-existence).
	 *
	 * @param dir - The directory path to read.
	 * @returns A promise that resolves to an array of directory entries, or an empty array on failure.
	 */
	private async safeReadDir(dir: string): Promise<fs.Dirent[]> {
		try {
			return await fs.promises.readdir(dir, { withFileTypes: true });
		} catch {
			this.logMsg("warn", `Skipping inaccessible directory: ${dir}...`);
			return [];
		}
	}

	/**
	 * Checks if a file path matches any of the provided exclusion patterns.
	 *
	 * @param filePath - The path of the file to check.
	 * @param excludeRegexes - An array of RegExp patterns to test against.
	 * @returns `true` if the file path matches any exclude pattern, otherwise `false`.
	 */
	private shouldExclude(filePath: string, rx: RegExp[]): boolean {
		const posix = this.toPosixPath(filePath);
		return rx.some((r) => r.test(filePath) || r.test(posix));
	}

	/**
	 * Creates a queue of absolute paths from the provided input directories.
	 *
	 * @param inputDirs - An array of input directory paths (relative or absolute).
	 * @returns An array of resolved absolute directory paths.
	 */
	private makeQueue(inputDirs: string[]): string[] {
		return [...inputDirs.map((dir) => path.resolve(dir))];
	}

	/**
	 * Processes a queue of directories to collect files matching given criteria.
	 *
	 * Recursively reads directories (if enabled), filters files by extension,
	 * filename pattern, and exclusion rules, and collects matching file paths.
	 *
	 * @param queue - The list of directories to process.
	 * @param exts - Allowed file extensions (normalized).
	 * @param nameRx - Regular expression to match valid filenames.
	 * @param excludeRx - Array of exclusion patterns.
	 * @param recursive - Whether to traverse subdirectories.
	 * @returns A promise that resolves to an array of matched file paths.
	 */
	private async processCollectionQueue(
		queue: string[],
		exts: string[],
		nameRx: RegExp,
		excludeRx: RegExp[],
		recursive: boolean,
	): Promise<string[]> {
		const found: string[] = [];

		while (queue.length) {
			const dir = queue.shift();

			// Realistically, this should never happen:
			/* v8 ignore start */
			if (!dir) {
				this.logMsg(
					"debug",
					`collectFiles: received falsy dir entry (${String(dir)}). This is unexpected and might indicate a bug.`,
				);
				continue;
			}
			/* v8 ignore stop */

			const entries = await this.safeReadDir(dir);
			for (const entry of entries) {
				const fullPath = path.resolve(dir, entry.name);
				this.handleEntry(entry, fullPath, queue, found, {
					exts,
					nameRx,
					excludeRx,
					recursive,
				});
			}
		}
		return found;
	}

	/**
	 * Handles a single directory entry during file collection.
	 *
	 * Applies exclusion rules, optionally queues directories for recursion,
	 * and collects files that match the specified extension and filename pattern.
	 *
	 * @param entry - The directory entry to handle.
	 * @param fullPath - The absolute path to the entry.
	 * @param queue - The processing queue for directories.
	 * @param found - The list to store matched file paths.
	 * @param opts - Options including extensions, name pattern, exclusions, and recursion flag.
	 */
	private handleEntry(
		entry: fs.Dirent,
		fullPath: string,
		queue: string[],
		found: string[],
		opts: {
			exts: string[];
			nameRx: RegExp;
			excludeRx: RegExp[];
			recursive: boolean;
		},
	): void {
		if (this.shouldExclude(fullPath, opts.excludeRx)) {
			return;
		}

		if (entry.isDirectory()) {
			if (opts.recursive) {
				queue.push(fullPath);
			}
			return;
		}

		if (
			entry.isFile() &&
			this.shouldCollectFile(entry, opts.exts, opts.nameRx)
		) {
			found.push(fullPath);
		}
	}

	/**
	 * Normalizes a filesystem path to POSIX format.
	 *
	 * Replaces platform-specific separators (e.g. `\` on Windows)
	 * with POSIX-style `/` to ensure consistent path handling
	 * across different operating systems.
	 *
	 * @param p - Original filesystem path.
	 * @returns The same path but with POSIX separators.
	 */
	private toPosixPath(p: string): string {
		return p.split(path.sep).join(path.posix.sep);
	}
}
