Source: modules/file.js

const VFS_STORAGE_KEY = 'jBB_FileSystem';

/**
 * Emulates a file system within the browser's localStorage,
 * providing a BlitzBasic-like API for file and directory operations.
 */
export class File{
	/** @private @type {object} In-memory representation of the virtual file system. */
	#vfs;
	/** @private @type {Array<object|null>} Handles for open files. Index 0 is unused. */
	#openFiles = [null];
	/** @private @type {Array<object|null>} Handles for open directories. Index 0 is unused. */
	#openDirs = [null];
	/** @private @type {string} Current working directory. */
	#currentDir = '/';

	constructor() {
		this.#loadVFS();
	}

	/**
	 * @private
	 * Loads the virtual file system from localStorage or creates a new one.
	 */
	#loadVFS() {
		const storedVFS = localStorage.getItem(VFS_STORAGE_KEY);
		if (storedVFS) {
			try {
				this.#vfs = JSON.parse(storedVFS);
			} catch (e) {
				console.error("jBB FileSystem: Could not parse VFS from localStorage. Resetting.", e);
				this.#createDefaultVFS();
			}
		} else {
			this.#createDefaultVFS();
		}
	}

	/**
	 * @private
	 * Saves the current in-memory VFS to localStorage.
	 */
	#saveVFS() {
		try {
			localStorage.setItem(VFS_STORAGE_KEY, JSON.stringify(this.#vfs));
		} catch (e) {
			console.error("jBB FileSystem: Could not save VFS to localStorage (storage might be full).", e);
		}
	}

	/**
	 * @private
	 * Creates the default root directory structure.
	 */
	#createDefaultVFS() {
		this.#vfs = {
			type: 'directory',
			name: '/',
			children: {}
		};
		this.#saveVFS();
	}

	/**
	 * @private
	 * Finds the first available null slot in a handle array or creates a new one.
	 * @param {Array} handleArray The array to search for a free handle.
	 * @returns {number} The free handle index.
	 */
	#getFreeHandle(handleArray) {
		for (let i = 1; i < handleArray.length; i++) {
			if (handleArray[i] === null) {
				return i;
			}
		}
		return handleArray.push(null) - 1;
	}

	/**
	 * @private
	 * Resolves a path string to a node in the VFS.
	 * @param {string} rawPath The path to resolve (e.g., "saves/game.dat" or "../stuff/file").
	 * @param {boolean} [createParents=false] If true, creates parent directories if they don't exist.
	 * @returns {{node: object, parent: object, name: string, resolvedParts: Array<string>}|null} The found node, its parent, its name, and the resolved path components.
	 */
	#resolvePath(rawPath, createParents = false) {
		const initialParts = rawPath.startsWith('/') ? [] : this.#currentDir.split('/').filter(p => p);
		const relativeParts = rawPath.split('/');

		for (const part of relativeParts) {
			if (part === '..') {
				initialParts.pop();
			} else if (part !== '.' && part !== '') {
				initialParts.push(part);
			}
		}
		const resolvedParts = initialParts;

		let currentNode = this.#vfs;
		let parentNode = null;

		for (let i = 0; i < resolvedParts.length; i++) {
			const part = resolvedParts[i];
			if (currentNode.type !== 'directory') {
				return null; // Can't traverse through a file
			}

			if (!currentNode.children[part]) {
				if (createParents && i < resolvedParts.length - 1) { // Create intermediate directory
					currentNode.children[part] = { type: 'directory', name: part, children: {} };
				} else {
					return { node: null, parent: currentNode, name: part, resolvedParts: resolvedParts };
				}
			}

			parentNode = currentNode;
			currentNode = currentNode.children[part];
		}

		return { node: currentNode, parent: parentNode, name: resolvedParts[resolvedParts.length - 1] || '/', resolvedParts: resolvedParts };
	}
	
	/**
	 * Opens a file for reading/writing. Creates the file if it doesn't exist.
	 * @param {string} path The path to the file.
	 * @returns {number} A file handle, or 0 on failure.
	 */
	openFile(path) {
		const resolved = this.#resolvePath(path);
		let fileNode = resolved ? resolved.node : null;
		if (!fileNode) {
			const createResolved = this.#resolvePath(path, true);
			if (!createResolved || !createResolved.parent) return 0;
			fileNode = { type: 'file', name: createResolved.name, content: '' };
			createResolved.parent.children[createResolved.name] = fileNode;
			this.#saveVFS();
		}

		if (fileNode.type !== 'file') return 0; // Path is a directory

		const handle = this.#getFreeHandle(this.#openFiles);
		this.#openFiles[handle] = { node: fileNode, pos: 0 };
		return handle;
	}

	/**
	 * Reads the next line from an open file.
	 * @param {number} handle The file handle returned by `openFile`.
	 * @returns {string} The content of the line, or an empty string if at the end of the file.
	 */
	readFile(handle) {
		const file = this.#openFiles[handle];
		if (!file || file.pos >= file.node.content.length) return '';

		const content = file.node.content;
		const newlineIndex = content.indexOf('\n', file.pos);

		let line;
		if (newlineIndex === -1) {
			line = content.substring(file.pos);
			file.pos = content.length;
		} else {
			line = content.substring(file.pos, newlineIndex);
			file.pos = newlineIndex + 1;
		}
		return line.replace('\r', ''); // Handle Windows line endings
	}

	/**
	 * Appends a line of text (with a newline character) to an open file.
	 * @param {number} handle The file handle.
	 * @param {string} text The text to write.
	 */
	writeFile(handle, text) {
		const file = this.#openFiles[handle];
		if (!file) return;

		const textWithNewline = text + '\n';
		file.node.content += textWithNewline;
		file.pos = file.node.content.length;
		this.#saveVFS();
	}

	/**
	 * Closes an open file handle.
	 * @param {number} handle The file handle to close.
	 */
	closeFile(handle) {
		if (this.#openFiles[handle]) {
			this.#openFiles[handle] = null;
		}
	}

	filePos(handle) {
		/**
		 * Gets the current read/write position in an open file.
		 * @param {number} handle The file handle.
		 * @returns {number} The current position (cursor) in the file.
		 */
		const file = this.#openFiles[handle];
		return file ? file.pos : 0;
	}

	/**
	 * Sets the read/write position for an open file.
	 * @param {number} handle The file handle.
	 * @param {number} pos The new position to seek to.
	 */
	seekFile(handle, pos) {
		const file = this.#openFiles[handle];
		if (file) {
			file.pos = Math.max(0, Math.min(pos, file.node.content.length));
		}
	}

	/**
	 * Checks if the end of an open file has been reached.
	 * @param {number} handle The file handle.
	 * @returns {boolean} True if the file cursor is at or beyond the end of the file.
	 */
	eof(handle) {
		const file = this.#openFiles[handle];
		return !file || file.pos >= file.node.content.length;
	}

	/**
	 * Opens a directory for reading its contents.
	 * @param {string} path The path to the directory.
	 * @returns {number} A directory handle, or 0 on failure.
	 */
	readDir(path) {
		const resolved = this.#resolvePath(path);
		if (!resolved || !resolved.node || resolved.node.type !== 'directory') return 0;

		const handle = this.#getFreeHandle(this.#openDirs);
		this.#openDirs[handle] = {
			keys: Object.keys(resolved.node.children),
			index: 0
		};
		return handle;
	}

	/**
	 * Reads the next entry from an open directory handle.
	 * @param {number} handle The directory handle.
	 * @returns {string} The name of the next file or directory, or an empty string if no more entries exist.
	 */
	nextFile(handle) {
		const dir = this.#openDirs[handle];
		if (dir && dir.index < dir.keys.length) {
			return dir.keys[dir.index++];
		}
		return '';
	}

	closeDir(handle) {
		if (this.#openDirs[handle]) {
			this.#openDirs[handle] = null;
		}
	}

	currentDir() {
		return this.#currentDir;
	}

	changeDir(path) {
		const resolved = this.#resolvePath(path);
		if (resolved && resolved.node && resolved.node.type === 'directory') {
			// Use the fully resolved, absolute path
			this.#currentDir = resolved.resolvedParts.length > 0 ? '/' + resolved.resolvedParts.join('/') : '/';
			return true;
		}
		return false;
	}

	createDir(path) {
		const resolved = this.#resolvePath(path, true);
		if (!resolved || !resolved.parent) return false;

		if (!resolved.node) {
			resolved.parent.children[resolved.name] = { type: 'directory', name: resolved.name, children: {} };
			this.#saveVFS();
			return true;
		}
		return resolved.node.type === 'directory'; // Return true if it already exists as a directory
	}

	/**
	 * Deletes an empty directory.
	 * @param {string} path The path of the directory to delete.
	 * @returns {boolean} True on success, false if the directory is not found or not empty.
	 */
	deleteDir(path) {
		const resolved = this.#resolvePath(path);
		if (resolved && resolved.node && resolved.node.type === 'directory' && resolved.parent) {
			if (Object.keys(resolved.node.children).length > 0) {
				return false; // Directory not empty
			}
			delete resolved.parent.children[resolved.name];
			this.#saveVFS();
			return true;
		}
		return false;
	}

	/**
	 * Determines the type of a path.
	 * @param {string} path The path to check.
	 * @returns {number} 0 for not found, 1 for a file, 2 for a directory.
	 */
	fileType(path) {
		const resolved = this.#resolvePath(path);
		if (!resolved || !resolved.node) return 0; // Not found
		if (resolved.node.type === 'file') return 1;
		if (resolved.node.type === 'directory') return 2;
		return 0;
	}

	/**
	 * Gets the size of a file in bytes.
	 * @param {string} path The path to the file.
	 * @returns {number} The size of the file, or 0 if not found or it's a directory.
	 */
	fileSize(path) {
		const resolved = this.#resolvePath(path);
		if (resolved && resolved.node && resolved.node.type === 'file') {
			return resolved.node.content.length;
		}
		return 0;
	}

	/**
	 * Copies a file from a source path to a destination path.
	 * @param {string} sourcePath The path of the file to copy.
	 * @param {string} destPath The destination path for the new file.
	 * @returns {boolean} True on success, false on failure.
	 */
	copyFile(sourcePath, destPath) {
		const sourceResolved = this.#resolvePath(sourcePath);
		if (!sourceResolved || !sourceResolved.node || sourceResolved.node.type !== 'file') return false;
		
		const sourceNode = sourceResolved.node;

		const destResolved = this.#resolvePath(destPath, true);
		if (!destResolved || !destResolved.parent) return false;

		destResolved.parent.children[destResolved.name] = {
			type: 'file',
			name: destResolved.name,
			content: sourceNode.content,
			lastModified: sourceNode.lastModified || null // Preserve the lastModified timestamp
		};
		this.#saveVFS();
		return true;
	}

	/**
	 * Deletes a file.
	 * @param {string} path The path of the file to delete.
	 * @returns {boolean} True on success, false if the file is not found or is currently open.
	 */
	deleteFile(path) {
		const resolved = this.#resolvePath(path);
		if (resolved && resolved.node && resolved.parent && resolved.node.type === 'file') {
			for (const fileHandle of this.#openFiles) {
				if (fileHandle && fileHandle.node === resolved.node) {
					console.error(`jBB FileSystem: Cannot delete file "${path}" because it is currently open.`);
					return false;
				}
			}
			delete resolved.parent.children[resolved.name];
			this.#saveVFS();
			return true;
		}
		return false;
	}

	/**
	 * Checks if a file exists at the given path.
	 * @param {string} path The path to check.
	 * @returns {boolean} True if a file exists, false otherwise.
	 */
	fileExists(path) {
		return this.fileType(path) === 1;
	}

	/**
	 * Triggers a browser download for a file stored in the VFS.
	 * @param {string} path The path of the file within the VFS to download.
	 * @param {string} [downloadName] Optional. The name for the downloaded file. Defaults to the file's name in the path.
	 * @returns {boolean} True if the download was initiated, false if the file was not found.
	 */
	downloadFile(path, downloadName) {
		const resolved = this.#resolvePath(path);
		if (!resolved || !resolved.node || resolved.node.type !== 'file') {
			console.error(`jBB FileSystem: File not found in VFS at path "${path}".`);
			return false;
		}

		const content = resolved.node.content;
		const blob = new Blob([content], { type: 'application/octet-stream' });
		const finalDownloadName = downloadName || resolved.name;

		const a = document.createElement('a');
		a.style.display = 'none';
		document.body.appendChild(a);

		const url = window.URL.createObjectURL(blob);
		a.href = url;
		a.download = finalDownloadName;
		a.click();

		window.URL.revokeObjectURL(url);
		document.body.removeChild(a);
		return true;
	}

	/**
	 * Asynchronously loads a text file. It first checks the VFS. If not found,
	 * it tries to fetch it from the server and caches it in the VFS for future use.
	 * @param {string} path The path to the text file.
	 * @returns {Promise<string|null>} A promise that resolves with the file content or null if not found.
	 */
	async loadTextFile(path) {
		const resolved = this.#resolvePath(path);
		const vfsNode = resolved?.node;

		if (vfsNode && vfsNode.type === 'file') {
			try {
				const headResponse = await fetch(path, { method: 'HEAD' });
				if (headResponse.ok) {
					const serverLastModified = headResponse.headers.get('Last-Modified');
					if (serverLastModified && vfsNode.lastModified === serverLastModified) {
						return vfsNode.content; // Serve from cache
					}
				}
			} catch (e) {
				// If HEAD request fails (e.g., CORS, network down), return the cached version for offline-like capabilities.
				console.warn(`jBB FileSystem: Could not check for update on "${path}". Serving from cache.`, e);
				return vfsNode.content;
			}
		}

		try {
			const getResponse = await fetch(path);
			if (!getResponse.ok) {
				if (vfsNode) {
					console.warn(`jBB FileSystem: Failed to refetch "${path}" (e.g. 404). Serving stale version from cache.`);
					return vfsNode.content;
				}
				return null;
			}
			
			const textContent = await getResponse.text();
			const newLastModified = getResponse.headers.get('Last-Modified');

			const destResolved = this.#resolvePath(path, true);
			if (destResolved && destResolved.parent) {
				destResolved.parent.children[destResolved.name] = { 
					type: 'file', 
					name: destResolved.name, 
					content: textContent,
					lastModified: newLastModified || null // Store the new timestamp or null
				};
				this.#saveVFS();
			}
			return textContent;
		} catch (error) {
			console.error(`jBB FileSystem: Network error during fetch for "${path}"`, error);
			if (vfsNode) {
				console.warn(`jBB FileSystem: Serving stale version of "${path}" due to network error.`);
				return vfsNode.content;
			}
			return null;
		}
	}
}