/**
 * Filesystem service for listing, reading, and writing files within a project.
 */
import { readdir, readFile as fsReadFile, writeFile as fsWriteFile, stat, realpath, mkdir, } from "node:fs/promises";
import { join, resolve, dirname } from "node:path";
import { MAX_FILE_SIZE, MAX_FS_ENTRIES, MAX_WRITE_SIZE } from "@pocketcoder/shared/constants";
/**
 * Validates that a resolved path is within the project root.
 * Prevents path traversal and symlink escapes.
 */
export async function validatePathWithinProject(projectPath, relativePath) {
    const resolvedProject = await realpath(projectPath);
    const targetPath = resolve(resolvedProject, relativePath);
    // First check normalized path to detect traversal attempts BEFORE attempting realpath
    // This catches cases like "../../../etc/passwd" where the file doesn't exist
    const normalized = resolve(targetPath);
    if (!normalized.startsWith(resolvedProject + "/") && normalized !== resolvedProject) {
        throw new PathEscapeError("Path escapes project directory");
    }
    // Then resolve symlinks and verify the real path is still within the project
    const resolvedTarget = await realpath(targetPath);
    if (!resolvedTarget.startsWith(resolvedProject + "/") && resolvedTarget !== resolvedProject) {
        throw new PathEscapeError("Path escapes project directory");
    }
    return resolvedTarget;
}
/**
 * Validates a path without requiring the target to exist (for new files).
 * Checks the parent directory is within the project root.
 */
export async function validateParentWithinProject(projectPath, relativePath) {
    const resolvedProject = await realpath(projectPath);
    const targetPath = resolve(resolvedProject, relativePath);
    // Normalize to remove .. segments
    const normalized = resolve(targetPath);
    if (!normalized.startsWith(resolvedProject + "/") && normalized !== resolvedProject) {
        throw new PathEscapeError("Path escapes project directory");
    }
    return normalized;
}
export class PathEscapeError extends Error {
    constructor(message) {
        super(message);
        this.name = "PathEscapeError";
    }
}
export class FileTooLargeError extends Error {
    size;
    limit;
    constructor(size, limit) {
        super(`File exceeds ${limit} byte limit (${size} bytes)`);
        this.name = "FileTooLargeError";
        this.size = size;
        this.limit = limit;
    }
}
export class UnsupportedFormatError extends Error {
    constructor(message) {
        super(message);
        this.name = "UnsupportedFormatError";
    }
}
export class FileNotFoundError extends Error {
    constructor(filePath) {
        super(`File not found: ${filePath}`);
        this.name = "FileNotFoundError";
    }
}
export class ContentTooLargeError extends Error {
    size;
    limit;
    constructor(size, limit) {
        super(`Content exceeds ${limit} byte limit (${size} bytes)`);
        this.name = "ContentTooLargeError";
        this.size = size;
        this.limit = limit;
    }
}
/**
 * Write a file within a project directory.
 * Creates parent directories if createDirs is true.
 * Returns path, size, and mtime.
 * Throws ContentTooLargeError if content exceeds MAX_WRITE_SIZE (1MB).
 * Throws PathEscapeError if path is outside project root.
 */
export async function writeFile(projectPath, relativePath, content, createDirs = true) {
    const contentBytes = Buffer.byteLength(content, "utf-8");
    if (contentBytes > MAX_WRITE_SIZE) {
        throw new ContentTooLargeError(contentBytes, MAX_WRITE_SIZE);
    }
    const resolvedPath = await validateParentWithinProject(projectPath, relativePath);
    if (createDirs) {
        await mkdir(dirname(resolvedPath), { recursive: true });
    }
    await fsWriteFile(resolvedPath, content, "utf-8");
    const fileStats = await stat(resolvedPath);
    // After writing, verify the real path is still within the project
    // (guards against TOCTOU with symlinks created between validation and write)
    const realResolved = await realpath(resolvedPath);
    const realProject = await realpath(projectPath);
    if (!realResolved.startsWith(realProject + "/") && realResolved !== realProject) {
        throw new PathEscapeError("Path escapes project directory");
    }
    return {
        path: relativePath,
        size: fileStats.size,
        mtime: fileStats.mtime.toISOString(),
    };
}
/**
 * Read a file within a project directory.
 * Returns content, encoding, size, and mtime.
 * Throws FileTooLargeError if file exceeds MAX_FILE_SIZE (10MB).
 * Throws UnsupportedFormatError if file is binary.
 * Throws FileNotFoundError if file does not exist.
 * Throws PathEscapeError if path is outside project root.
 */
export async function readFile(projectPath, relativePath) {
    let resolvedPath;
    try {
        resolvedPath = await validatePathWithinProject(projectPath, relativePath);
    }
    catch (err) {
        // realpath throws ENOENT if the file doesn't exist
        if (err instanceof PathEscapeError)
            throw err;
        const code = err.code;
        if (code === "ENOENT")
            throw new FileNotFoundError(relativePath);
        throw err;
    }
    let fileStats;
    try {
        fileStats = await stat(resolvedPath);
    }
    catch {
        throw new FileNotFoundError(relativePath);
    }
    if (!fileStats.isFile()) {
        throw new FileNotFoundError(relativePath);
    }
    if (fileStats.size > MAX_FILE_SIZE) {
        throw new FileTooLargeError(fileStats.size, MAX_FILE_SIZE);
    }
    const buffer = await fsReadFile(resolvedPath);
    // Detect binary content by checking for null bytes in the first 8KB
    if (isBinary(buffer)) {
        throw new UnsupportedFormatError("Binary file format not supported");
    }
    return {
        content: buffer.toString("utf-8"),
        encoding: "utf-8",
        size: fileStats.size,
        mtime: fileStats.mtime.toISOString(),
    };
}
/**
 * Check if a buffer contains binary data by looking for null bytes.
 */
function isBinary(buffer) {
    const checkLength = Math.min(buffer.length, 8192);
    for (let i = 0; i < checkLength; i++) {
        if (buffer[i] === 0) {
            return true;
        }
    }
    return false;
}
/**
 * List directory contents at a given path within a project.
 * Returns FsNode[] with lazy loading (depth parameter).
 */
export async function listDirectory(projectPath, relativePath, depth = 1) {
    const resolvedProject = await realpath(projectPath);
    const targetPath = relativePath === ""
        ? resolvedProject
        : await validatePathWithinProject(projectPath, relativePath);
    const children = [];
    let truncated = false;
    await collectEntries(resolvedProject, targetPath, relativePath, depth, children, {
        truncated: false,
        count: 0,
    });
    if (children.length > MAX_FS_ENTRIES) {
        truncated = true;
        children.length = MAX_FS_ENTRIES;
    }
    return { children, truncated };
}
async function collectEntries(projectRoot, dirPath, relativeDir, depth, result, state) {
    if (depth <= 0 || state.count >= MAX_FS_ENTRIES)
        return;
    let entries;
    try {
        entries = await readdir(dirPath, { withFileTypes: true });
    }
    catch {
        return;
    }
    // Sort: directories first, then alphabetically
    entries.sort((a, b) => {
        const aIsDir = a.isDirectory() ? 0 : 1;
        const bIsDir = b.isDirectory() ? 0 : 1;
        if (aIsDir !== bIsDir)
            return aIsDir - bIsDir;
        return a.name.localeCompare(b.name);
    });
    for (const entry of entries) {
        if (state.count >= MAX_FS_ENTRIES) {
            state.truncated = true;
            return;
        }
        const entryPath = join(dirPath, entry.name);
        const entryRelative = relativeDir === "" ? entry.name : `${relativeDir}/${entry.name}`;
        const node = {
            name: entry.name,
            path: entryRelative,
            type: entry.isDirectory() ? "dir" : "file",
        };
        if (!entry.isDirectory()) {
            try {
                const stats = await stat(entryPath);
                node.size = stats.size;
                node.mtime = stats.mtime.toISOString();
            }
            catch {
                // Skip files we can't stat
            }
        }
        else {
            try {
                const stats = await stat(entryPath);
                node.mtime = stats.mtime.toISOString();
            }
            catch {
                // Skip directories we can't stat
            }
        }
        result.push(node);
        state.count++;
        // Recurse into subdirectories if depth > 1
        if (entry.isDirectory() && depth > 1) {
            node.children = [];
            node.expanded = true;
            await collectEntries(projectRoot, entryPath, entryRelative, depth - 1, node.children, state);
        }
    }
}
//# sourceMappingURL=filesystem.js.map