import { createVitestConfigWithDecorators } from '@n8n/vitest-config/node-decorators';
import fs from 'node:fs';
import path from 'node:path';
// This LanguageService-based entity transform needs the real JS compiler API.
// The package builds on TypeScript 6, so `typescript` resolves to it directly;
// when it migrates to tsgo (TS7, no JS API) it should adopt the `typescript-tooling`
// catalog so `typescript` keeps resolving to the TS6 API.
import ts from 'typescript';
import { mergeConfig, type Plugin } from 'vite';
import { configDefaults } from 'vitest/config';

/**
 * Vite plugin that transpiles this package's TypeORM entity files (`src/entities/**`)
 * through the real TypeScript compiler (a full `ts.LanguageService`, not single-file
 * `transpileModule`). Every other source file is left to Vite's fast oxc transform.
 *
 * TypeORM entities rely on `emitDecoratorMetadata` to derive column types from the
 * reflected `design:type`. For a string-literal union column (e.g.
 * `providerType: AuthProviderType`, where the alias is imported from another file),
 * only `tsc` with cross-file type information collapses the union to `String`. Vite's
 * oxc transform — and SWC — emit `Object` instead, which TypeORM rejects at
 * `DataSource.initialize()`. Single-file `transpileModule` also emits `Object` because
 * it can't resolve the imported alias. A full Program is required — the same
 * reason a non-isolated-modules TypeScript compile is needed here.
 *
 * Scoping to `src/entities/**` keeps the cost contained: only the ~50 entity files pay
 * the tsc price (and the Program is rooted there), while DI `@Service` constructor
 * metadata — which oxc emits correctly — keeps the fast path for the rest of `src`.
 */
function tscDecoratorTransform(): Plugin {
	const projectDir = __dirname;
	const entitiesDir = path.resolve(projectDir, 'src', 'entities') + path.sep;
	let emit: ((fileName: string) => { code: string; map: unknown } | null) | undefined;

	function createEmitter() {
		const configPath = ts.findConfigFile(projectDir, ts.sys.fileExists, 'tsconfig.json');
		if (!configPath) {
			throw new Error('Could not find tsconfig.json for @n8n/db');
		}

		const { config } = ts.readConfigFile(configPath, ts.sys.readFile);
		const parsed = ts.parseJsonConfigFileContent(config, ts.sys, projectDir);
		// Root the Program at the entity files only; their imported types (e.g. the union
		// aliases in `types-db.ts`, related entities) are still resolved on demand via the
		// host's snapshot reads, so cross-file metadata stays correct.
		const rootFiles = parsed.fileNames.filter((f) => path.normalize(f).startsWith(entitiesDir));

		const options: ts.CompilerOptions = {
			...parsed.options,
			module: ts.ModuleKind.ESNext,
			target: ts.ScriptTarget.ES2022,
			moduleResolution: ts.ModuleResolutionKind.Bundler,
			experimentalDecorators: true,
			emitDecoratorMetadata: true,
			verbatimModuleSyntax: false,
			isolatedModules: false,
			sourceMap: true,
			inlineSources: true,
			skipLibCheck: true,
			noEmit: false,
			noEmitOnError: false,
			declaration: false,
			declarationMap: false,
			composite: false,
			incremental: false,
			tsBuildInfoFile: undefined,
		};

		const versions = new Map<string, number>();
		const contents = new Map<string, string>();
		for (const f of rootFiles) {
			versions.set(path.normalize(f), 0);
		}

		// Re-read `fileName` from disk and, if its content changed since the last read,
		// bump the script version so the LanguageService invalidates its cached emit.
		function refresh(fileName: string): string | undefined {
			const norm = path.normalize(fileName);
			const next = fs.existsSync(norm) ? fs.readFileSync(norm, 'utf-8') : undefined;
			if (next !== contents.get(norm)) {
				if (next === undefined) {
					contents.delete(norm);
				} else {
					contents.set(norm, next);
				}

				versions.set(norm, (versions.get(norm) ?? 0) + 1);
			}
			return next;
		}

		const host: ts.LanguageServiceHost = {
			getScriptFileNames: () => Array.from(versions.keys()),
			getScriptVersion: (f) => String(versions.get(path.normalize(f)) ?? 0),
			getScriptSnapshot: (f) => {
				const cached = contents.get(path.normalize(f));
				const text = cached ?? (fs.existsSync(f) ? fs.readFileSync(f, 'utf-8') : undefined);
				return text === undefined ? undefined : ts.ScriptSnapshot.fromString(text);
			},
			getCurrentDirectory: () => projectDir,
			getCompilationSettings: () => options,
			getDefaultLibFileName: (o) => ts.getDefaultLibFilePath(o),
			fileExists: ts.sys.fileExists,
			readFile: ts.sys.readFile,
			readDirectory: ts.sys.readDirectory,
			directoryExists: ts.sys.directoryExists,
			getDirectories: ts.sys.getDirectories,
		};

		const service = ts.createLanguageService(host, ts.createDocumentRegistry());

		return (fileName: string) => {
			const norm = path.normalize(fileName);
			// Pick up on-disk edits (watch mode) by bumping the script version when the
			// content changes; otherwise the LanguageService reuses a stale cached emit.
			if (refresh(norm) === undefined) {
				return null;
			}

			const output = service.getEmitOutput(norm);
			const js = output.outputFiles.find((f) => f.name.endsWith('.js'));
			const map = output.outputFiles.find((f) => f.name.endsWith('.js.map'));
			if (!js) {
				return null;
			}

			return { code: js.text, map: map ? (JSON.parse(map.text) as unknown) : null };
		};
	}

	return {
		name: 'tsc-decorator-transform',
		enforce: 'pre',
		transform(_code, id) {
			const file = id.split('?')[0];
			if (!file.startsWith(entitiesDir) || !/\.tsx?$/.test(file)) return null;
			emit ??= createEmitter();
			const result = emit(file);
			return result ? { code: result.code, map: result.map as never } : null;
		},
	};
}

export default mergeConfig(createVitestConfigWithDecorators({}), {
	plugins: [tscDecoratorTransform()],
	test: {
		// Vitest 4's default exclude is only node_modules/.git — it does NOT cover dist.
		// Without this, compiled test files left in dist (tsc never deletes orphaned
		// output) get collected and fail (CJS `require('vitest')`). The build also
		// excludes test files now, but this guards against pre-existing stale artifacts.
		exclude: [...configDefaults.exclude, '**/dist/**'],
	},
});
