/**
 * File Watcher for Dev Server Hot Reload
 *
 * Watches source files and triggers server restart on changes.
 * Handles both backend (API, agents, lib) and generates restart signals.
 */

import { watch, type FSWatcher, statSync, readdirSync, lstatSync } from 'node:fs';
import { resolve, relative } from 'node:path';
import type { Logger } from '../../types';
import { createAgentTemplates } from './templates';

export interface FileWatcherOptions {
	rootDir: string;
	logger: Logger;
	onRestart: () => void;
	additionalPaths?: string[];
}

export interface FileWatcherManager {
	start: () => void;
	stop: () => void;
	pause: () => void;
	resume: () => void;
}

/**
 * Create a file watcher manager for hot reload
 */
export function createFileWatcher(options: FileWatcherOptions): FileWatcherManager {
	const { rootDir, logger, onRestart, additionalPaths = [] } = options;

	const watchers: FSWatcher[] = [];
	let paused = false;
	let buildCooldownTimer: NodeJS.Timeout | null = null;

	// Directories to ignore - these are NEVER traversed into
	// This prevents EMFILE errors from symlink cycles in node_modules
	const ignoreDirs = new Set([
		'.agentuity',
		'.agents',
		'.claude',
		'.code',
		'.opencode',
		'node_modules',
		'.git',
		'.github',
		'dist',
		'build',
		'.next',
		'.turbo',
		'.npm',
		'.dist',
		'.vscode',
		'.idea',
		'.DS_Store',
		'.playwright',
		'.bun',
		'src/generated',
	]);

	// Paths to ignore for file change events (but may still be traversed)
	const ignorePaths = [
		'.agentuity',
		'.agents',
		'.claude',
		'.code',
		'.opencode',
		'node_modules',
		'.git',
		'.github',
		'dist',
		'build',
		'.next',
		'.turbo',
		'.npm',
		'.dist',
		'.vscode',
		'.idea',
		'.bun',
		'.DS_Store',
		'.playwright',
		'src/web', // Vite handles frontend with HMR - no backend restart needed
		'src/generated', // Generated files shouldn't trigger rebuilds
	];

	// File extensions to ignore - non-code files that shouldn't trigger reload
	const ignoreExtensions = new Set([
		'.md',
		'.mdx',
		'.txt',
		'.log',
		'.lock',
		'.yaml',
		'.yml',
		'.toml',
		'.csv',
		'.svg',
		'.png',
		'.jpg',
		'.jpeg',
		'.gif',
		'.webp',
		'.ico',
		'.woff',
		'.woff2',
		'.ttf',
		'.eot',
		'.mp4',
		'.mp3',
		'.wav',
		'.ogg',
		'.webm',
		'.pdf',
		'.zip',
		'.tar',
		'.gz',
		'.map',
	]);

	/**
	 * Check if a path should be ignored
	 */
	function shouldIgnorePath(changedFile: string | null, watchDir: string): boolean {
		if (!changedFile) return false;

		const absPath = resolve(watchDir, changedFile);

		// Check against ignore list - match both relative path and absolute path
		for (const ignorePath of ignorePaths) {
			// Check relative path from watchDir
			if (
				changedFile === ignorePath ||
				changedFile.startsWith(`${ignorePath}/`) ||
				changedFile.startsWith(`${ignorePath}\\`)
			) {
				logger.trace('File change ignored (%s): %s', ignorePath, changedFile);
				return true;
			}

			// Check if absolute path contains the ignore pattern
			const ignoreAbsPath = resolve(rootDir, ignorePath);
			if (
				absPath === ignoreAbsPath ||
				absPath.startsWith(`${ignoreAbsPath}/`) ||
				absPath.startsWith(`${ignoreAbsPath}\\`)
			) {
				logger.trace('File change ignored (%s): %s', ignorePath, changedFile);
				return true;
			}

			// Also check if changedFile path includes the ignore pattern anywhere
			// This handles cases like "some/path/.agentuity/file.js"
			const normalizedChanged = changedFile.replace(/\\/g, '/');
			const normalizedIgnore = ignorePath.replace(/\\/g, '/');
			if (
				normalizedChanged.includes(`/${normalizedIgnore}/`) ||
				normalizedChanged.includes(`/${normalizedIgnore}`)
			) {
				logger.trace('File change ignored (%s in path): %s', ignorePath, changedFile);
				return true;
			}
		}

		// Ignore temp files from editors
		if (changedFile.match(/\.(tmp|swp|swo|swx)$|~$/)) {
			logger.trace('File change ignored (temp file): %s', changedFile);
			return true;
		}

		// Ignore files with non-code extensions
		const extIndex = changedFile.lastIndexOf('.');
		if (extIndex !== -1) {
			const ext = changedFile.slice(extIndex).toLowerCase();
			if (ignoreExtensions.has(ext)) {
				logger.trace('File change ignored (%s): %s', ext, changedFile);
				return true;
			}
		}

		// Ignore hidden files (except .env)
		if (changedFile.startsWith('.') && !changedFile.startsWith('.env')) {
			logger.trace('File change ignored (hidden file): %s', changedFile);
			return true;
		}

		return false;
	}

	/**
	 * Handle file change event
	 */
	function handleFileChange(eventType: string, changedFile: string | null, watchDir: string) {
		if (paused) {
			logger.trace('File change ignored (watcher paused): %s', changedFile);
			return;
		}

		if (shouldIgnorePath(changedFile, watchDir)) {
			return;
		}

		// During build cooldown, ignore changes (they're likely build outputs)
		if (buildCooldownTimer) {
			logger.trace('File change ignored (build cooldown): %s', changedFile);
			return;
		}

		// Get absolute path for checking
		const absPath = changedFile ? resolve(watchDir, changedFile) : watchDir;

		// Check if this is a directory
		let isDirectory = false;
		try {
			const stats = statSync(absPath);
			isDirectory = stats.isDirectory();
		} catch {
			// File doesn't exist (was deleted) - not a directory
			isDirectory = false;
		}

		// Check if an empty directory was created in src/agent/
		// This helps with developer experience by auto-scaffolding template files
		if (changedFile && eventType === 'rename' && isDirectory) {
			try {
				// Normalize the path for comparison (use forward slashes)
				const normalizedPath = changedFile.replace(/\\/g, '/');

				// Check if it's empty
				const contents = readdirSync(absPath);
				if (contents.length === 0) {
					if (
						normalizedPath.startsWith('src/agent/') ||
						normalizedPath.includes('/src/agent/')
					) {
						logger.debug('Agent directory created: %s', changedFile);
						createAgentTemplates(absPath);
					}
				}
			} catch (error) {
				// File might have been deleted or doesn't exist yet - this is normal
				logger.trace(
					'Unable to check directory for template creation (%s): %s',
					changedFile,
					error
				);
			}
		}

		// Ignore directory-only change events (mtime updates when files inside change)
		// We only care about actual file changes - the watcher will report those directly
		if (isDirectory && eventType === 'change') {
			logger.trace('File change ignored (directory mtime update): %s', changedFile);
			return;
		}

		logger.debug('File changed (%s): %s', eventType, changedFile || watchDir);
		onRestart();
	}

	/**
	 * Recursively collect all directories to watch, skipping ignored directories.
	 * This prevents EMFILE errors from symlink cycles in node_modules.
	 */
	function collectWatchDirs(dir: string, visited: Set<string> = new Set()): string[] {
		const dirs: string[] = [dir];

		try {
			// Use lstat to check for symlinks - get the real path to detect cycles
			const stat = lstatSync(dir);

			// Skip symlinks to prevent following circular symlinks
			if (stat.isSymbolicLink()) {
				logger.trace('Skipping symlink: %s', dir);
				return [];
			}

			// Track visited inodes to detect cycles
			const key = `${stat.dev}:${stat.ino}`;
			if (visited.has(key)) {
				logger.trace('Skipping already visited directory (cycle detected): %s', dir);
				return [];
			}
			visited.add(key);

			const entries = readdirSync(dir, { withFileTypes: true });

			for (const entry of entries) {
				if (!entry.isDirectory()) continue;

				const name = entry.name;

				// Skip ignored directories entirely - this is the key fix
				if (ignoreDirs.has(name)) {
					logger.trace('Skipping ignored directory: %s', resolve(dir, name));
					continue;
				}

				// Skip hidden directories (except specific ones like .env folders)
				if (name.startsWith('.')) {
					logger.trace('Skipping hidden directory: %s', resolve(dir, name));
					continue;
				}

				const fullPath = resolve(dir, name);
				dirs.push(...collectWatchDirs(fullPath, visited));
			}
		} catch (error) {
			logger.trace('Error reading directory %s: %s', dir, error);
		}

		return dirs;
	}

	/**
	 * Start watching files
	 */
	function start() {
		logger.debug('Starting file watchers for hot reload...');

		// Collect all directories to watch, excluding node_modules and other ignored dirs
		const allDirs = collectWatchDirs(rootDir);

		// Add additional paths
		if (additionalPaths && additionalPaths.length > 0) {
			for (const additionalPath of additionalPaths) {
				const fullPath = resolve(rootDir, additionalPath);
				allDirs.push(...collectWatchDirs(fullPath));
			}
		}

		// De-duplicate directories
		const uniqueDirs = [...new Set(allDirs)];

		logger.debug('Collected %d directories to watch', uniqueDirs.length);

		// Watch each directory non-recursively
		for (const watchPath of uniqueDirs) {
			try {
				// Use non-recursive watch to avoid traversing into node_modules
				const watcher = watch(watchPath, { recursive: false }, (eventType, changedFile) => {
					// Construct relative path from rootDir for consistent handling
					const relPath = changedFile
						? relative(rootDir, resolve(watchPath, changedFile))
						: relative(rootDir, watchPath);
					handleFileChange(eventType, relPath || changedFile, rootDir);
				});

				watchers.push(watcher);
			} catch (error) {
				logger.trace('Failed to start watcher for %s: %s', watchPath, error);
			}
		}

		logger.debug('File watchers started (%d directories)', watchers.length);
	}

	/**
	 * Stop all watchers
	 */
	function stop() {
		logger.debug('Stopping file watchers...');

		for (const watcher of watchers) {
			try {
				watcher.close();
			} catch (error) {
				logger.trace('Error closing watcher: %s', error);
			}
		}

		watchers.length = 0;

		if (buildCooldownTimer) {
			clearTimeout(buildCooldownTimer);
			buildCooldownTimer = null;
		}

		logger.debug('File watchers stopped');
	}

	/**
	 * Temporarily pause watching (e.g., during build)
	 */
	function pause() {
		paused = true;
		logger.trace('File watchers paused');

		// Set cooldown timer to ignore changes for a bit after build
		if (buildCooldownTimer) {
			clearTimeout(buildCooldownTimer);
		}

		buildCooldownTimer = setTimeout(() => {
			buildCooldownTimer = null;
			logger.trace('Build cooldown expired');
		}, 500); // 500ms cooldown
	}

	/**
	 * Resume watching
	 */
	function resume() {
		paused = false;

		// Clear any pending build cooldown timer so file changes are detected immediately.
		// Without this, events arriving during the remaining cooldown window after a fast
		// typecheck failure would be silently dropped.
		if (buildCooldownTimer) {
			clearTimeout(buildCooldownTimer);
			buildCooldownTimer = null;
		}

		logger.trace('File watchers resumed');
	}

	return {
		start,
		stop,
		pause,
		resume,
	};
}
