// @generated — DO NOT EDIT. Source: packages/shared/resolve-file.ts
/**
 * Smart markdown file resolution.
 *
 * Resolves a user-provided path to an absolute file path using three strategies:
 * 1. Exact path (absolute or relative to cwd)
 * 2. Case-insensitive relative path search within project root
 * 3. Case-insensitive bare filename search within project root
 *
 * Used by both the CLI (`plannotator annotate`) and the `/api/doc` endpoint.
 */

import { homedir } from "os";
import { isAbsolute, join, resolve, win32 } from "path";
import { existsSync, readdirSync, type Dirent } from "fs";

const MARKDOWN_PATH_REGEX = /\.mdx?$/i;

import { CODE_FILE_REGEX as CODE_FILE_BASENAME_REGEX } from "./code-file";
export { CODE_FILE_REGEX, isCodeFilePath } from "./code-file";

const WINDOWS_DRIVE_PATH_PATTERNS = [
	/^\/cygdrive\/([a-zA-Z])\/(.+)$/,
	/^\/([a-zA-Z])\/(.+)$/,
];

const IGNORED_DIRS = [
	"node_modules/",
	".git/",
	"dist/",
	"build/",
	".next/",
	"__pycache__/",
	".obsidian/",
	".trash/",
];

const CODE_IGNORED_DIRS = [
	...IGNORED_DIRS,
	".turbo/",
	".cache/",
	"target/",
	"vendor/",
	"coverage/",
	".venv/",
	".pytest_cache/",
];

export type ResolveResult =
	| { kind: "found"; path: string }
	| { kind: "not_found"; input: string }
	| { kind: "ambiguous"; input: string; matches: string[] }
	| { kind: "unavailable"; input: string };

function normalizeSeparators(input: string): string {
	return input.replace(/\\/g, "/");
}

function stripTrailingSlashes(input: string): string {
	return input.replace(/\/+$/, "");
}

export function expandHomePath(input: string, home = homedir()): string {
	if (input === "~") {
		return home;
	}

	if (input.startsWith("~/") || input.startsWith("~\\")) {
		return join(home, input.slice(2));
	}

	return input;
}

export function stripWrappingQuotes(input: string): string {
	if (input.length < 2) {
		return input;
	}

	const first = input[0];
	const last = input[input.length - 1];
	if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
		return input.slice(1, -1);
	}

	return input;
}

export function normalizeUserPathInput(
	input: string,
	platform = process.platform,
): string {
	const trimmedInput = input.trim();
	const unquotedInput = stripWrappingQuotes(trimmedInput);
	const expandedInput = expandHomePath(unquotedInput);

	if (platform !== "win32") {
		return expandedInput;
	}

	for (const pattern of WINDOWS_DRIVE_PATH_PATTERNS) {
		const match = expandedInput.match(pattern);
		if (!match) {
			continue;
		}

		const [, driveLetter, rest] = match;
		return `${driveLetter.toUpperCase()}:/${rest}`;
	}

	return expandedInput;
}

function isAbsoluteNormalizedUserPath(
	input: string,
	platform = process.platform,
): boolean {
	if (hasWindowsDriveLetter(input)) {
		return true;
	}

	return platform === "win32"
		? win32.isAbsolute(input)
		: isAbsolute(input);
}

export function isAbsoluteUserPath(
	input: string,
	platform = process.platform,
): boolean {
	return isAbsoluteNormalizedUserPath(normalizeUserPathInput(input, platform), platform);
}

export function resolveUserPath(
	input: string,
	baseDir = process.cwd(),
	platform = process.platform,
): string {
	const normalizedInput = normalizeUserPathInput(input, platform);
	if (!normalizedInput) {
		return "";
	}
	return isAbsoluteNormalizedUserPath(normalizedInput, platform)
		? resolveAbsolutePath(normalizedInput, platform)
		: resolve(baseDir, normalizedInput);
}

function normalizeComparablePath(input: string): string {
	return stripTrailingSlashes(normalizeSeparators(resolveUserPath(input)));
}

export function isWithinProjectRoot(candidate: string, projectRoot: string): boolean {
	const normalizedCandidate = normalizeComparablePath(candidate);
	const normalizedProjectRoot = normalizeComparablePath(projectRoot);
	return (
		normalizedCandidate === normalizedProjectRoot ||
		normalizedCandidate.startsWith(`${normalizedProjectRoot}/`)
	);
}

function getLowercaseBasename(input: string): string {
	const normalizedInput = normalizeSeparators(input);
	return normalizedInput.split("/").pop()!.toLowerCase();
}

function getLookupKey(input: string, isBareFilename: boolean): string {
	return isBareFilename ? getLowercaseBasename(input) : input.toLowerCase();
}

function resolveAbsolutePath(
	input: string,
	platform = process.platform,
): string {
	// Use win32.resolve for Windows paths regardless of reported platform
	return platform === "win32" || hasWindowsDriveLetter(input)
		? win32.resolve(input)
		: resolve(input);
}

function isSearchableMarkdownPath(input: string): boolean {
	return MARKDOWN_PATH_REGEX.test(input.trim());
}

/** Check if a path looks like a Windows absolute path (e.g. C:\ or C:/) */
function hasWindowsDriveLetter(input: string): boolean {
	return /^[a-zA-Z]:[/\\]/.test(input);
}

/** Cross-platform file existence check using Node fs (more reliable than Bun.file in compiled exes) */
function fileExists(filePath: string): boolean {
	try {
		return existsSync(filePath);
	} catch {
		return false;
	}
}

/** Recursively walk a directory collecting files matching `fileMatcher`, skipping ignored dirs. */
function walkFiles(
	dir: string,
	root: string,
	results: string[],
	ignoredDirs: string[],
	fileMatcher: (name: string) => boolean,
): void {
	const entries = readdirSync(dir, { withFileTypes: true }) as Dirent[];
	for (const entry of entries) {
		if (entry.isDirectory()) {
			if (ignoredDirs.some((d) => d === entry.name + "/")) continue;
			try {
				walkFiles(join(dir, entry.name), root, results, ignoredDirs, fileMatcher);
			} catch {
				/* skip dirs we can't read */
			}
		} else if (entry.isFile() && fileMatcher(entry.name)) {
			const relative = join(dir, entry.name)
				.slice(root.length + 1)
				.replace(/\\/g, "/");
			results.push(relative);
		}
	}
}

function walkMarkdownFiles(dir: string, root: string, results: string[], ignoredDirs: string[]): void {
	try {
		walkFiles(dir, root, results, ignoredDirs, (name) => /\.mdx?$/i.test(name));
	} catch {
		/* fail soft for markdown — preserves existing behavior */
	}
}

// --- Code-file resolution (async, cached) ---

const FILE_LIST_CACHE_TTL_MS = 30_000;
const fileListCache = new Map<
	string,
	{ promise: Promise<string[] | null>; startedAt: number }
>();

function fileListCacheKey(projectRoot: string, kind: string): string {
	return `${projectRoot}|${kind}`;
}

function startCodeWalk(projectRoot: string): Promise<string[] | null> {
	return Promise.resolve().then(() => {
		try {
			const results: string[] = [];
			walkFiles(projectRoot, projectRoot, results, CODE_IGNORED_DIRS, (name) =>
				CODE_FILE_BASENAME_REGEX.test(name),
			);
			return results;
		} catch {
			return null;
		}
	});
}

/**
 * Trigger (or return the in-flight) walk of `projectRoot` for code files.
 * Cached for `FILE_LIST_CACHE_TTL_MS`. Storing a Promise (not a value) makes
 * concurrent callers piggyback on the same walk — first arrival wins.
 *
 * Returns `null` (wrapped in Promise) when the walk fails (perms, etc).
 */
export function warmFileListCache(
	projectRoot: string,
	kind: "code",
): Promise<string[] | null> {
	const key = fileListCacheKey(projectRoot, kind);
	const entry = fileListCache.get(key);
	if (entry && Date.now() - entry.startedAt < FILE_LIST_CACHE_TTL_MS) {
		return entry.promise;
	}
	const promise = startCodeWalk(projectRoot);
	fileListCache.set(key, { promise, startedAt: Date.now() });
	return promise;
}

/**
 * Resolve a code-file path within a project root.
 *
 * Strategies:
 *   1. Absolute path → use as-is.
 *   2. Exact relative from project root.
 *   3. If `baseDir` provided, literal `<baseDir>/<input>` existence check —
 *      lets out-of-tree linked docs resolve their own relative references
 *      (e.g. `../script.ts` in `~/notes/foo.md` finds `~/script.ts`).
 *   4. Case-insensitive suffix match against the cached file list:
 *      - bare basename input → match any file with that basename;
 *      - input with `/` → match files whose path equals or ends with `/<input>`
 *        on a segment boundary (so `editor/App.tsx` matches `packages/editor/App.tsx`
 *        but not `myeditor/App.tsx`).
 *
 * `..` segments in the input are honored: only `./` is stripped before suffix
 * matching. `../foo.ts` without a `baseDir` correctly falls through to
 * not_found rather than fabricating a match against `foo.ts` somewhere in cwd.
 */
export async function resolveCodeFile(
	input: string,
	projectRoot: string,
	baseDir?: string,
): Promise<ResolveResult> {
	const originalInput = input.trim();
	const unquotedInput = stripWrappingQuotes(originalInput);
	const normalizedInput = normalizeUserPathInput(unquotedInput);
	const searchInput = normalizeSeparators(normalizedInput);

	if (!searchInput) {
		return { kind: "not_found", input: originalInput };
	}

	if (isAbsoluteNormalizedUserPath(normalizedInput)) {
		const absolutePath = resolveAbsolutePath(normalizedInput);
		if (fileExists(absolutePath)) {
			return { kind: "found", path: absolutePath };
		}
		return { kind: "not_found", input: originalInput };
	}

	const fromRoot = resolve(projectRoot, searchInput);
	if (isWithinProjectRoot(fromRoot, projectRoot) && fileExists(fromRoot)) {
		return { kind: "found", path: fromRoot };
	}

	if (baseDir) {
		const fromBase = resolve(baseDir, searchInput);
		if (fileExists(fromBase)) {
			return { kind: "found", path: fromBase };
		}
	}

	const fileList = await warmFileListCache(projectRoot, "code");
	if (fileList === null) {
		return { kind: "unavailable", input: originalInput };
	}

	// Strip leading `./` so suffix matching works on inputs like
	// `./editor/App.tsx` — file list entries never carry that segment.
	// `../` is intentionally NOT stripped: `..` is meaningful (escape parent),
	// not noise. If we can't honor it via baseDir, the input has no
	// suffix-match equivalent in the in-tree file list.
	const cleanedInput = searchInput.replace(/^(?:\.\/)+/, "");
	if (!cleanedInput || cleanedInput.startsWith("../")) {
		return { kind: "not_found", input: originalInput };
	}
	const target = cleanedInput.toLowerCase();
	const isBareFilename = !cleanedInput.includes("/");
	const matches: string[] = [];

	for (const f of fileList) {
		const fl = f.toLowerCase();
		if (isBareFilename) {
			const base = fl.split("/").pop();
			if (base === target) matches.push(resolve(projectRoot, f));
		} else {
			if (fl === target || fl.endsWith("/" + target)) {
				matches.push(resolve(projectRoot, f));
			}
		}
	}

	if (matches.length === 1) {
		return { kind: "found", path: matches[0] };
	}
	if (matches.length > 1) {
		return { kind: "ambiguous", input: originalInput, matches };
	}
	return { kind: "not_found", input: originalInput };
}

/**
 * Resolve a markdown file path within a project root.
 *
 * @param input - User-provided path (absolute, relative, or bare filename)
 * @param projectRoot - Project root directory to search within
 */
function resolveMarkdownFileCore(
	input: string,
	projectRoot: string,
): ResolveResult {
	const normalizedInput = normalizeUserPathInput(input);
	const searchInput = normalizeSeparators(normalizedInput);
	const isBareFilename = !searchInput.includes("/");
	const targetLookupKey = getLookupKey(searchInput, isBareFilename);

	// Restrict to markdown files
	if (!isSearchableMarkdownPath(normalizedInput)) {
		return { kind: "not_found", input };
	}

	// 1. Absolute path — use as-is (no project root restriction;
	//    the user explicitly typed the full path)
	if (isAbsoluteNormalizedUserPath(normalizedInput)) {
		const absolutePath = resolveAbsolutePath(normalizedInput);
		if (fileExists(absolutePath)) {
			return { kind: "found", path: absolutePath };
		}
		return { kind: "not_found", input };
	}

	// 2. Exact relative path from project root
	const fromRoot = resolve(projectRoot, searchInput);
	if (isWithinProjectRoot(fromRoot, projectRoot) && fileExists(fromRoot)) {
		return { kind: "found", path: fromRoot };
	}

	// 3. Case-insensitive search (only scan markdown files)
	const allFiles: string[] = [];
	walkMarkdownFiles(projectRoot, projectRoot, allFiles, IGNORED_DIRS);
	const matches: string[] = [];

	for (const match of allFiles) {
		const normalizedMatch = normalizeSeparators(match);
		const matchLookupKey = getLookupKey(normalizedMatch, isBareFilename);

		if (matchLookupKey === targetLookupKey) {
			const full = resolve(projectRoot, normalizedMatch);
			if (isWithinProjectRoot(full, projectRoot)) {
				matches.push(full);
			}
		}
	}

	if (matches.length === 1) {
		return { kind: "found", path: matches[0] };
	}
	if (matches.length > 1) {
		const projectRootPrefix = `${normalizeComparablePath(projectRoot)}/`;
		const relative = matches.map((match) =>
			normalizeComparablePath(match).replace(projectRootPrefix, ""),
		);
		return { kind: "ambiguous", input, matches: relative };
	}

	return { kind: "not_found", input };
}

/**
 * Resolve a markdown file path within a project root.
 *
 * @param input - User-provided path (absolute, relative, or bare filename)
 * @param projectRoot - Project root directory to search within
 */
export function resolveMarkdownFile(
	input: string,
	projectRoot: string,
): ResolveResult {
	const originalInput = input.trim();
	const unquotedInput = stripWrappingQuotes(originalInput);

	const primary = resolveMarkdownFileCore(unquotedInput, projectRoot);
	if (primary.kind === "found") {
		return primary;
	}
	if (primary.kind === "ambiguous") {
		return { ...primary, input: originalInput };
	}

	if (!unquotedInput.startsWith("@")) {
		return { kind: "not_found", input: originalInput };
	}

	const normalizedInput = unquotedInput.replace(/^@+/, "");
	if (!normalizedInput) {
		return { kind: "not_found", input: originalInput };
	}

	const fallback = resolveMarkdownFileCore(normalizedInput, projectRoot);
	if (fallback.kind === "found") {
		return fallback;
	}
	if (fallback.kind === "ambiguous") {
		return { ...fallback, input: originalInput };
	}

	return { kind: "not_found", input: originalInput };
}

/**
 * Check if a directory contains at least one file matching the given extensions.
 * Used to validate folder annotation targets.
 *
 * @param dirPath - Directory to search
 * @param excludedDirs - Directory names to skip (with trailing slash, e.g. "node_modules/")
 * @param extensions - Regex to match file extensions (default: markdown only)
 */
export function hasMarkdownFiles(
	dirPath: string,
	excludedDirs: string[] = IGNORED_DIRS,
	extensions: RegExp = /\.mdx?$/i,
): boolean {
	function walk(dir: string): boolean {
		let entries;
		try {
			entries = readdirSync(dir, { withFileTypes: true });
		} catch {
			return false;
		}
		for (const entry of entries) {
			if (entry.isDirectory()) {
				if (excludedDirs.some((d) => d === entry.name + "/")) continue;
				if (walk(join(dir, entry.name))) return true;
			} else if (entry.isFile() && extensions.test(entry.name)) {
				return true;
			}
		}
		return false;
	}
	return walk(dirPath);
}
