#!/usr/bin/env node
/**
 * check-test-imports.mjs — catch missing ESM named imports in test files.
 *
 * Detects the anti-pattern where a test file uses itemized `import { foo } from "..."`
 * but references a name that is not in the import list (e.g. a new `describe(...)`
 * block uses `buildFoo()` but only `{ buildBar }` was imported). JS surfaces this as
 * `ReferenceError` at vitest run-time — slow feedback. This script detects it at
 * lint time using static analysis.
 *
 * Usage:
 *   node scripts/check-test-imports.mjs              # scan all test files
 *   node scripts/check-test-imports.mjs --json       # JSON output
 *   npm run check:test-imports                      # via npm script
 *
 * Exit 0 = no issues found. Exit 1 = drift detected (or scan error).
 *
 * Coverage: all `*.test.{js,mjs,ts}` and `*.spec.{js,mjs,ts}` files under tests/,
 * src/, and packages/ (respects .gitignore via glob). Node built-ins and
 * package:// resolved imports are excluded automatically.
 */

import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { readdirSync, statSync, readFileSync } from "node:fs";

const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, "..");

// ── Helpers ───────────────────────────────────────────────────────────────────

const NODE_BUILTINS = new Set([
	"process",
	"require",
	"module",
	"exports",
	"console",
	"global",
	"Buffer",
	"setTimeout",
	"setInterval",
	"clearTimeout",
	"clearInterval",
	"queueMicrotask",
	"Array",
	"Object",
	"String",
	"Number",
	"Boolean",
	"Symbol",
	"BigInt",
	"Map",
	"Set",
	"WeakMap",
	"WeakSet",
	"Promise",
	"Math",
	"JSON",
	"Date",
	"RegExp",
	"Error",
	"EvalError",
	"RangeError",
	"ReferenceError",
	"SyntaxError",
	"TypeError",
	"URIError",
	"AggregateError",
	"AbortController",
	"AbortSignal",
	"URL",
	"URLSearchParams",
	"TextEncoder",
	"TextDecoder",
	"TransformStream",
	"ReadableStream",
	"WritableStream",
	"Blob",
	"File",
	"FormData",
	"Headers",
	"Request",
	"Response",
	"fetch",
	"WebSocket",
	"EventSource",
	"setImmediate",
	"clearImmediate",
	"structuredClone",
	"atob",
	"btoa",
	"navigator",
	"window",
	"document",
	"location",
	"history",
	"gc",
	"finalizationRegistry",
	"Intl",
	"Reflect",
	"Proxy",
	// Node.js globals (not ESM builtins but always available)
	"__dirname",
	"__filename",
	"import",
	"imports",
]);

// Node.js module exports commonly used in tests without explicit import
const NODE_MODULE_GLOBALS = new Set([
	"assert",
	"assertEq",
	"deepEqual",
	"strictEqual",
	"path",
	"fs",
	"os",
	"url",
	"querystring",
	"crypto",
	"buffer",
	"stream",
	"util",
	"events",
	"querystring",
	"stringDecoder",
	"readline",
	"tty",
	"domain",
	"constants",
	" timers",
	"async_hooks",
]);

// JavaScript reserved words and keywords that should never be flagged as undeclared
const JS_KEYWORDS = new Set([
	// Reserved words
	"break",
	"case",
	"catch",
	"continue",
	"debugger",
	"default",
	"delete",
	"do",
	"else",
	"finally",
	"for",
	"function",
	"if",
	"in",
	"instanceof",
	"new",
	"return",
	"switch",
	"this",
	"throw",
	"try",
	"typeof",
	"var",
	"void",
	"while",
	"with",
	"class",
	"const",
	"enum",
	"export",
	"extends",
	"import",
	"super",
	"implements",
	"interface",
	"let",
	"package",
	"private",
	"protected",
	"public",
	"static",
	"yield",
	"async",
	"await",
	"of",
	"get",
	"set",
	"target",
	"from",
	"as",
	// TypeScript keywords / modifiers
	"any",
	"unknown",
	"never",
	"undefined",
	"null",
	"true",
	"false",
	"type",
	"namespace",
	"module",
	"declare",
	"abstract",
	"readonly",
	"is",
	"keyof",
	"infer",
	"satisfies",
	"out",
	"override",
	// TypeScript built-in utility types
	"Partial",
	"Required",
	"Readonly",
	"Pick",
	"Omit",
	"Record",
	"Exclude",
	"Extract",
	"NonNullable",
	"Parameters",
	"ConstructorParameters",
	"ReturnType",
	"InstanceType",
	"ThisParameterType",
	"OmitThisParameter",
	"ThisType",
	"Uppercase",
	"Lowercase",
	"Capitalize",
	"Uncapitalize",
	"Awaited",
	// Primitive type names used as TS annotations
	"string",
	"number",
	"boolean",
	"bigint",
	"symbol",
	"object",
]);

// Common test framework globals that are always available in vitest/jest
const TEST_GLOBALS = new Set([
	"describe",
	"it",
	"test",
	"expect",
	"beforeEach",
	"afterEach",
	"beforeAll",
	"afterAll",
	"suite",
	"spec",
	"context",
	"given",
	"when",
	"then",
	"and",
	"describe.skip",
	"it.skip",
	"test.skip",
	"describe.only",
	"it.only",
	"test.only",
	"describe.each",
	"it.each",
	"test.each",
	"describe.concurrent",
	"it.concurrent",
	"test.concurrent",
	"vi",
	"vitest",
	"spyOn",
	"jest",
]);

// Boolean and literal values that always exist
const JS_LITERALS = new Set([
	"true",
	"false",
	"null",
	"undefined",
	"NaN",
	"Infinity",
]);

/**
 * Parse the statically-imported identifiers from the top of a file.
 * Returns { named: Set<string>, namespace: Set<string> } where namespace is
 * all identifiers that come from a `import * as X from` namespace import.
 */
function parseImports(source) {
	const named = new Set();
	const namespace = new Set();

	// Strip strings, template literals, and comments
	const stripped = source
		.replace(/'[^']*'/g, "'_'")
		.replace(/"[^"]*"/g, '"_"')
		.replace(/`[^`]*`/g, "``_`")
		.replace(/\/\/[^\n]*/g, "")
		.replace(/\/\*[\s\S]*?\*\//g, "");

	// Match: import * as NS from "module" or import * as NS from 'module'
	const nsRe = /import\s+\*\s+as\s+([a-zA-Z_$][\w$]*)\s+from\s+["'][^"']+["']/g;
	for (const m of stripped.matchAll(nsRe)) {
		namespace.add(m[1]);
	}

	// Match: import { a, b, c } from "module" or import { a as b } from "module"
	// Handles: { a }, { a, b }, { a as b }, { type a }
	const namedRe = /import\s+\{([^}]+)\}\s+from\s+["'][^"']+["']/g;
	for (const m of stripped.matchAll(namedRe)) {
		const content = m[1];
		const items = content
			.split(",")
			.map((s) => {
				s = s.trim();
				if (s.includes(" as ")) {
					s = s.split(" as ").pop().trim();
				}
				if (s.startsWith("type ")) {
					s = s.replace("type ", "").trim();
				}
				return s.replace(/\s+/g, "");
			})
			.filter(Boolean);
		items.forEach((id) => named.add(id));
	}

	// Default imports: import foo from "module" → add "foo" as a named import
	// (it IS declared and usable without prefix)
	const defaultRe = /import\s+([a-zA-Z_$][\w$]*)\s+from\s+["'][^"']+["']/g;
	for (const m of stripped.matchAll(defaultRe)) {
		named.add(m[1]);
	}

	// Side-effect imports: import "module" → no named declarations
	// (handled — nothing to extract)

	return { named, namespace };
}

/**
 * Collect identifiers declared LOCALLY in the file (functions, var/let/const,
 * destructured patterns, function/arrow parameters). These should not be
 * flagged as "undeclared" — they are declared, just not via an import. Used
 * to suppress false positives where the file defines its own helpers/locals
 * inside the test body.
 */
function collectLocalDeclarations(source) {
	const decls = new Set();
	// Strip strings and comments first so we don't pick up identifiers inside
	// quoted text (e.g. assertion strings, template literals).
	const stripped = source
		.replace(/'[^']*'/g, "'_'")
		.replace(/"[^"]*"/g, '"_"')
		.replace(/`[^`]*`/g, "``_`")
		.replace(/\/\/[^\n]*/g, "")
		.replace(/\/\*[\s\S]*?\*\//g, "");

	// `function NAME(` — function declarations (named + async + generator)
	for (const m of stripped.matchAll(
		/\b(?:async\s+)?function\s*\*?\s*([a-zA-Z_$][\w$]*)\s*\(/g,
	)) {
		decls.add(m[1]);
	}
	// `class NAME` — class declarations
	for (const m of stripped.matchAll(/\bclass\s+([a-zA-Z_$][\w$]*)\b/g)) {
		decls.add(m[1]);
	}
	// `const/let/var NAME = ...` — simple single-name declarations
	for (const m of stripped.matchAll(
		/\b(?:const|let|var)\s+([a-zA-Z_$][\w$]*)\b/g,
	)) {
		decls.add(m[1]);
	}
	// `const/let/var { a, b: c, ...d } = ...` — object destructuring
	for (const m of stripped.matchAll(
		/\b(?:const|let|var)\s*\{\s*([^}]+)\s*\}\s*=/g,
	)) {
		const inner = m[1];
		for (const part of inner.split(",")) {
			// Patterns: `name`, `name: alias`, `name = default`, `...rest`
			const cleaned = part
				.trim()
				.replace(/^\.\.\./, "")
				.split(":")
				.pop()
				.split("=")[0]
				.trim();
			const idMatch = cleaned.match(/^([a-zA-Z_$][\w$]*)$/);
			if (idMatch) decls.add(idMatch[1]);
		}
	}
	// `const/let/var [a, b, ...c] = ...` — array destructuring
	for (const m of stripped.matchAll(
		/\b(?:const|let|var)\s*\[\s*([^\]]+)\s*\]\s*=/g,
	)) {
		const inner = m[1];
		for (const part of inner.split(",")) {
			const cleaned = part
				.trim()
				.replace(/^\.\.\./, "")
				.split("=")[0]
				.trim();
			const idMatch = cleaned.match(/^([a-zA-Z_$][\w$]*)$/);
			if (idMatch) decls.add(idMatch[1]);
		}
	}
	// `function name(a, b: T, { c, d } = {}, ...rest)` — function params.
	// Also handles arrow functions: `(a, b) => ...` and method shorthand.
	for (const m of stripped.matchAll(
		/(?:function\s*\*?\s*[a-zA-Z_$]?[\w$]*\s*|=>|\bcatch\s*)\(\s*([^)]*?)\s*\)/g,
	)) {
		const inner = m[1];
		if (!inner) continue;
		for (const part of inner.split(",")) {
			const cleaned = part
				.trim()
				.replace(/^\.\.\./, "")
				.split(":")[0]
				.split("=")[0]
				.trim();
			const idMatch = cleaned.match(/^([a-zA-Z_$][\w$]*)$/);
			if (idMatch) decls.add(idMatch[1]);
		}
	}
	// `enum NAME` — TS enum declarations
	for (const m of stripped.matchAll(/\benum\s+([a-zA-Z_$][\w$]*)\b/g)) {
		decls.add(m[1]);
	}
	// `interface NAME` / `type NAME =` — TS type declarations
	for (const m of stripped.matchAll(
		/\b(?:interface|type)\s+([a-zA-Z_$][\w$]*)\b/g,
	)) {
		decls.add(m[1]);
	}
	return decls;
}

/**
 * Collect all identifier references in the source, excluding:
 * - strings, comments, template literals
 * - keyword/member access patterns (foo.bar, obj[key])
 */
function collectReferences(source) {
	// Strip strings, template literals, and comments first
	const stripped = source
		.replace(/'[^']*'/g, "'_'")
		.replace(/"[^"]*"/g, '"_"')
		.replace(/`[^`]*`/g, "``_`")
		.replace(/\/\/[^\n]*/g, "")
		.replace(/\/\*[\s\S]*?\*\//g, "");

	// Remove member-access suffixes (foo.bar → foo)
	// Remove computed-property access (obj[key] → obj, key)
	// Remove calls (foo() → foo)
	// We want bare identifiers only

	// Replace member expressions and calls with placeholders to isolate identifiers
	const cleaned = stripped
		.replace(/\b[a-zA-Z_$][\w$]*\.[a-zA-Z_$][\w$]*(?=\s*[[.(])/g, "_")
		.replace(/\[[^\]]+\]/g, "[_]");

	// Now collect bare identifiers (word boundaries)
	const idRe = /\b([a-zA-Z_$][\w$]*)\b/g;
	const ids = new Set();
	for (const m of cleaned.matchAll(idRe)) {
		ids.add(m[1]);
	}
	return ids;
}

/**
 * Check a file for import drift: itemized imports referencing undeclared names.
 *
 * The target anti-pattern: a test file has itemized imports (≥6 from one source)
 * and references a camelCase identifier that isn't in the import list — a new
 * describe block that uses a function without adding it to the imports.
 *
 * We only flag this when the itemized count is high because that's when the
 * manual-maintenance risk is highest. Files with few itemized imports or
 * namespace imports have low drift risk.
 *
 * Returns null if clean, or an issue description string.
 */
function checkFile(filePath, source) {
	const imports = parseImports(source);
	const refs = collectReferences(source);
	const localDecls = collectLocalDeclarations(source);

	const itemizedCount = imports.named.size;

	// Collect candidate undeclared identifiers (filtering chain)
	const undeclared = new Set();
	for (const id of refs) {
		if (imports.named.has(id)) continue;
		if (imports.namespace.has(id)) continue;
		if (localDecls.has(id)) continue;
		if (NODE_BUILTINS.has(id)) continue;
		if (NODE_MODULE_GLOBALS.has(id)) continue;
		if (JS_KEYWORDS.has(id)) continue;
		if (JS_LITERALS.has(id)) continue;
		if (TEST_GLOBALS.has(id)) continue;
		if (id.length <= 2) continue;
		if (id === id.toUpperCase() && id.length <= 5) continue;
		undeclared.add(id);
	}

	if (undeclared.size === 0) return null;

	// Additional filtering for false positives:
	// Common test-file patterns that look like undeclared but are actually local:
	// - Boolean flags: canLaunch*, has*, is*, should*, will*
	// - Variable names assigned in try/catch or before import
	// - Playwright/MCP test helpers
	const COMMON_TEST_LOCALS = new Set([
		"canLaunchChromium",
		"isAvailable",
		"hasSupport",
		"isSupported",
		"describeOrSkip",
		"itOrSkip",
		"skipIf",
		"runIf",
		"jiti",
		"interopDefault",
		"debug",
	]);

	const filteredUndeclared = [...undeclared].filter(
		(id) =>
			!COMMON_TEST_LOCALS.has(id) &&
			!id.match(/^(can|has|is|should|will)[A-Z]/) &&
			!id.match(/^(testBrowser|chromiumInstance|pageInstance)$/),
	);

	if (filteredUndeclared.length === 0) return null;

	// Only flag when the anti-pattern is present:
	// itemized imports from one module AND a camelCase identifier missing from the list.
	// This targets the specific failure mode: a new `describe("buildFoo v2")` block
	// that uses `buildFoo()` but the import list wasn't updated.
	// Files with namespace imports or few itemized imports have lower drift risk.
	if (itemizedCount < 6) return null;

	return `Itemized imports (${itemizedCount}) + undeclared identifier(s): ${filteredUndeclared.slice(0, 8).join(", ")}${filteredUndeclared.length > 8 ? " ..." : ""}`;
}

// ── Main ──────────────────────────────────────────────────────────────────────

function findTestFiles(dir) {
	const results = [];
	try {
		for (const entry of readdirSync(dir, { withFileTypes: true })) {
			const full = resolve(dir, entry.name);
			if (entry.isDirectory()) {
				// Skip test artifacts, build output, and system directories
				if (
					entry.name === "node_modules" ||
					entry.name === "dist" ||
					entry.name === ".git" ||
					entry.name === "__pycache__" ||
					entry.name === "tmp-check-test-imports" // regression test temp dir
				) {
					continue;
				}
				results.push(...findTestFiles(full));
			} else if (
				(entry.name.endsWith(".test.js") ||
					entry.name.endsWith(".test.mjs") ||
					entry.name.endsWith(".test.ts") ||
					entry.name.endsWith(".spec.js") ||
					entry.name.endsWith(".spec.mjs") ||
					entry.name.endsWith(".spec.ts")) &&
				!entry.name.includes("node_modules")
			) {
				results.push(full);
			}
		}
	} catch {
		// Skip inaccessible dirs
	}
	return results;
}

const args = process.argv.slice(2);
const jsonOut = args.includes("--json");
const verbose = args.includes("--verbose");

// If specific paths are provided, use those instead of scanning
// Filter out only flag args, keep everything else as a file path
const explicitPaths = args.filter((a) => !a.startsWith("--"));

let allFiles = [];
if (explicitPaths.length > 0) {
	allFiles = explicitPaths
		.map((p) => resolve(root, p))
		.filter((p) => {
			try {
				statSync(p);
				return true;
			} catch {
				console.error(`check-test-imports: ${p} does not exist, skipping`);
				return false;
			}
		});
	if (allFiles.length === 0) {
		console.error("check-test-imports: no valid files specified");
		process.exit(1);
	}
	if (verbose) {
		console.error(
			`[check-test-imports] explicit mode: ${allFiles.length} file(s)`,
		);
	}
} else {
	const scanDirs = [
		resolve(root, "tests"),
		resolve(root, "src"),
		resolve(root, "packages"),
	];
	for (const dir of scanDirs) {
		allFiles.push(...findTestFiles(dir));
	}
}

const issues = [];
let clean = 0;

for (const file of allFiles) {
	try {
		const source = readFileSync(file, "utf-8");
		const rel = file.replace(root + "/", "");

		const result = checkFile(file, source);
		if (result) {
			issues.push({ file: rel, problem: result });
		} else {
			clean++;
		}
	} catch (err) {
		// Non-critical: skip files we can't read
	}
}

if (jsonOut) {
	console.log(JSON.stringify({ issues, count: issues.length, clean }, null, 2));
} else if (issues.length === 0) {
	const label = verbose ? ` (${clean} files checked)` : "";
	console.log(`✅  No import drift detected.${label}`);
} else {
	console.log(`❌  Import drift detected in ${issues.length} file(s):\n`);
	for (const { file, problem } of issues) {
		console.log(`  ${file}`);
		console.log(`    → ${problem}`);
	}
	if (verbose) {
		console.log(`\n  (${clean} files checked, no issues found)`);
	}
}

process.exit(issues.length > 0 ? 1 : 0);
