[tab] src/capture/filesystemWatcher.ts (typescript)
/**
 * Filesystem Watcher Module
 * Watches for file changes from external sources (agents, git operations)
 */

import * as vscode from 'vscode'
import * as fs from 'node:fs'
import * as crypto from 'crypto'
import type { CachedFileContent } from '../types'

// Configuration
const DEBOUNCE_WINDOW_MS = 500
const MAX_CACHE_SIZE = 100 // Maximum number of files to cache

// Exclusion patterns
const EXCLUDED_PATTERNS = [
	'/.git/',
	'/node_modules/',
	'/dist/',
	'/.crowd-code/',
	'/out/',
	'/.vscode/',
	'/coverage/',
	'/__pycache__/',
	'/.pytest_cache/',
	'/build/',
	'/target/',
]

// File content cache for diff computation
const fileCache = new Map<string, CachedFileContent>()
const fileCacheAccessOrder: string[] = [] // LRU tracking

// Debounce tracking
const pendingChanges = new Map<string, {
	type: 'create' | 'change' | 'delete'
	timeout: NodeJS.Timeout
}>()

// Filesystem watcher
let fileWatcher: vscode.FileSystemWatcher | null = null

// Callbacks
let onFileChangeCallback: ((
	file: string,
	changeType: 'create' | 'change' | 'delete',
	diff: string | null
) => void) | null = null

/**
 * Check if a file path should be excluded from watching
 */
function isExcluded(filePath: string): boolean {
	const normalizedPath = filePath.replace(/\\/g, '/')
	return EXCLUDED_PATTERNS.some(pattern => normalizedPath.includes(pattern))
}

/**
 * Compute a simple diff between old and new content
 * Returns a string representation of the changes
 */
function computeDiff(oldContent: string | null, newContent: string | null): string | null {
	if (oldContent === null && newContent === null) {
		return null
	}

	if (oldContent === null) {
		// File created
		return `+++ New file
${newContent}`
	}

	if (newContent === null) {
		// File deleted
		return `--- Deleted file
${oldContent}`
	}

	if (oldContent === newContent) {
		return null // No change
	}

	// Simple line-based diff
	const oldLines = oldContent.split('
')
	const newLines = newContent.split('
')

	const diff: string[] = []
	
	// Find common prefix
	let commonStart = 0
	while (commonStart < oldLines.length && commonStart < newLines.length &&
		   oldLines[commonStart] === newLines[commonStart]) {
		commonStart++
	}

	// Find common suffix
	let commonEnd = 0
	while (commonEnd < oldLines.length - commonStart && 
		   commonEnd < newLines.length - commonStart &&
		   oldLines[oldLines.length - 1 - commonEnd] === newLines[newLines.length - 1 - commonEnd]) {
		commonEnd++
	}

	// Generate diff for changed section
	const oldStart = commonStart
	const oldEnd = oldLines.length - commonEnd
	const newStart = commonStart
	const newEnd = newLines.length - commonEnd

	if (oldStart < oldEnd || newStart < newEnd) {
		diff.push(`@@ -${oldStart + 1},${oldEnd - oldStart} +${newStart + 1},${newEnd - newStart} @@`)
		
		for (let i = oldStart; i < oldEnd; i++) {
			diff.push(`-${oldLines[i]}`)
		}
		for (let i = newStart; i < newEnd; i++) {
			diff.push(`+${newLines[i]}`)
		}
	}

	return diff.length > 0 ? diff.join('
') : null
}

/**
 * Get cached content for a file
 */
export function getCachedContent(filePath: string): string | null {
	const cached = fileCache.get(filePath)
	return cached ? cached.content : null
}

/**
 * Update the cache for a file
 */
function updateCache(filePath: string, content: string | null): void {
	if (content === null) {
		fileCache.delete(filePath)
		const idx = fileCacheAccessOrder.indexOf(filePath)
		if (idx !== -1) {
			fileCacheAccessOrder.splice(idx, 1)
		}
		return
	}

	fileCache.set(filePath, {
		content,
		timestamp: Date.now()
	})

	// Update LRU order
	const idx = fileCacheAccessOrder.indexOf(filePath)
	if (idx !== -1) {
		fileCacheAccessOrder.splice(idx, 1)
	}
	fileCacheAccessOrder.push(filePath)

	// Enforce cache size limit (LRU eviction)
	while (fileCacheAccessOrder.length > MAX_CACHE_SIZE) {
		const oldest = fileCacheAccessOrder.shift()
		if (oldest) {
			fileCache.delete(oldest)
		}
	}
}

/**
 * Read file content safely
 */
async function readFileContent(filePath: string): Promise<string | null> {
	try {
		const content = await fs.promises.readFile(filePath, 'utf-8')
		return content
	} catch {
		return null
	}
}

/**
 * Compute content hash for deduplication
 */
export function computeContentHash(content: string): string {
	return crypto.createHash('md5').update(content).digest('hex')
}

/**
 * Process a file change event (after debounce)
 */
async function processFileChange(
	filePath: string,
	changeType: 'create' | 'change' | 'delete'
): Promise<void> {
	if (!onFileChangeCallback) {
		return
	}

	const oldContent = getCachedContent(filePath)
	let newContent: string | null = null
	let diff: string | null = null

	if (changeType === 'delete') {
		diff = computeDiff(oldContent, null)
		updateCache(filePath, null)
	} else {
		newContent = await readFileContent(filePath)
		if (newContent !== null) {
			diff = computeDiff(oldContent, newContent)
			updateCache(filePath, newContent)
		}
	}

	// Only fire callback if there's an actual change
	if (diff !== null || changeType === 'create' || changeType === 'delete') {
		onFileChangeCallback(filePath, changeType, diff)
	}
}

/**
 * Handle a file system event with debouncing
 */
function handleFileEvent(uri: vscode.Uri, eventType: 'create' | 'change' | 'delete'): void {
	const filePath = uri.fsPath

	// Check exclusions
	if (isExcluded(filePath)) {
		return
	}

	// Clear any pending change for this file
	const pending = pendingChanges.get(filePath)
	if (pending) {
		clearTimeout(pending.timeout)
	}

	// Set up debounced processing
	const timeout = setTimeout(() => {
		pendingChanges.delete(filePath)
		processFileChange(filePath, eventType)
	}, DEBOUNCE_WINDOW_MS)

	pendingChanges.set(filePath, {
		type: eventType,
		timeout
	})
}

/**
 * Set the callback for file change events
 */
export function setFileChangeCallback(
	callback: ((file: string, changeType: 'create' | 'change' | 'delete', diff: string | null) => void) | null
): void {
	onFileChangeCallback = callback
}

/**
 * Pre-populate the cache with content of currently open files
 */
async function initializeCache(): Promise<void> {
	for (const document of vscode.workspace.textDocuments) {
		if (document.uri.scheme === 'file' && !isExcluded(document.fileName)) {
			updateCache(document.fileName, document.getText())
		}
	}
}

/**
 * Update cache when a file is opened or changed via VS Code API
 * This should be called by the recording orchestrator when user actions occur
 */
export function updateCacheFromDocument(document: vscode.TextDocument): void {
	if (document.uri.scheme === 'file' && !isExcluded(document.fileName)) {
		updateCache(document.fileName, document.getText())
	}
}

/**
 * Initialize the filesystem watcher
 */
export async function initializeFilesystemWatcher(context: vscode.ExtensionContext): Promise<void> {
	const workspaceFolder = vscode.workspace.workspaceFolders?.[0]
	if (!workspaceFolder) {
		return
	}

	// Initialize cache with currently open files
	await initializeCache()

	// Create filesystem watcher
	fileWatcher = vscode.workspace.createFileSystemWatcher(
		new vscode.RelativePattern(workspaceFolder, '**/*')
	)

	fileWatcher.onDidCreate((uri) => handleFileEvent(uri, 'create'))
	fileWatcher.onDidChange((uri) => handleFileEvent(uri, 'change'))
	fileWatcher.onDidDelete((uri) => handleFileEvent(uri, 'delete'))

	context.subscriptions.push(fileWatcher)
}

/**
 * Cleanup the filesystem watcher
 */
export function cleanupFilesystemWatcher(): void {
	if (fileWatcher) {
		fileWatcher.dispose()
		fileWatcher = null
	}

	// Clear pending changes
	for (const [, pending] of pendingChanges) {
		clearTimeout(pending.timeout)
	}
	pendingChanges.clear()

	// Don't clear the cache - it might be useful for the next recording
}

/**
 * Reset the filesystem watcher state (invalidate cache)
 */
export function resetFilesystemState(): void {
	fileCache.clear()
	fileCacheAccessOrder.length = 0

	// Clear pending changes
	for (const [, pending] of pendingChanges) {
		clearTimeout(pending.timeout)
	}
	pendingChanges.clear()
}

/**
 * Force a cache refresh for all tracked files
 * Useful after git operations that may have changed many files
 */
export async function refreshCache(): Promise<void> {
	const filesToRefresh = [...fileCache.keys()]
	
	for (const filePath of filesToRefresh) {
		const content = await readFileContent(filePath)
		if (content !== null) {
			updateCache(filePath, content)
		} else {
			// File no longer exists
			updateCache(filePath, null)
		}
	}

	// Also refresh from open documents
	await initializeCache()
}