import fs from "fs/promises";
import nodePath from "node:path";
import * as t from "@babel/types";
import { PROCESSED_TRANSACTIONS_ATTRIBUTE, ROOT_WIDGET_ATTRIBUTE, SOURCE_ID_ATTRIBUTE, } from "@superblocksteam/library-shared";
import { addTracingToMethods, TracedEventEmitter, } from "@superblocksteam/shared";
import { readAppApiYamlFile, writeApiFiles, } from "@superblocksteam/util";
import { glob } from "glob";
import { isEqual, debounce } from "lodash-es";
import { normalizePath } from "vite";
import yaml from "yaml";
import { generateJSXAttribute } from "./codegen.js";
import { ComponentsManager } from "./components-manager.js";
import { addLegacyCustomComponentVariables, modifyLegacyCustomComponentElements, modifyLegacyCustomComponentImports, } from "./custom-components.js";
import { applyErrorHandling, } from "./errors/error-handler.js";
import { getApiFilePath, isApiFilePath, isPageFilePath, PAGES_DIRECTORY, SCOPE_FILE, } from "./file-system-helpers.js";
import { generate } from "./generate.js";
import { doesNodePathHaveBinding } from "./parsing/bindings.js";
import { getSbElementId } from "./parsing/ids.js";
import { makeJSXAttribute } from "./parsing/jsx.js";
import { getRootNodes } from "./parsing/page.js";
import { getLegacyComponentsFolder, isCustomBuildEnabled, } from "./plugin-options.js";
import { RenameManager } from "./rename-manager.js";
import { getFullImportPath } from "./resolver.js";
import { findRouterFile } from "./router-parser.js";
import { SourceTracker } from "./source-tracker.js";
import { traverse } from "./traverse.js";
import { getErrorMeta, getLogger } from "./util/logger.js";
import { getScopeIdFromName, getScopeNameFromPath } from "./util.js";
const SUPPORTED_FILETYPES = [
    {
        type: "ts",
        extension: ".ts",
    },
    {
        type: "tsx",
        extension: ".tsx",
    },
    {
        type: "scope",
        extension: SCOPE_FILE,
    },
    {
        type: "api",
        extension: ".yaml",
    },
    {
        type: "python-api-step",
        extension: ".py",
    },
    {
        type: "js-api-step",
        extension: ".js",
    },
    {
        type: "json",
        extension: ".json",
    },
];
export class FileSystemManager extends TracedEventEmitter {
    rootDir;
    tsFiles = {};
    apiFiles = {};
    sourceTracker;
    fsOperationQueue;
    generationNumberSequence;
    routeChangesQueue = [];
    pageAdditionQueue = new Set();
    routerFilePath = null;
    watcher;
    registeredComponentPaths = {};
    renameManager = new RenameManager();
    _tracer;
    transactionNonce = Date.now();
    pendingTransactions = new Set();
    processedTransactions = [];
    debouncedFileUpdateHandlers = new Map();
    templateRenderer;
    constructor(fsOperationQueue, generationNumberSequence, tracer) {
        super(tracer, { captureRejections: true });
        this.rootDir = "/";
        this.fsOperationQueue = fsOperationQueue;
        this.generationNumberSequence = generationNumberSequence;
        this._tracer = tracer;
        applyErrorHandling(this, {
            watch: { operation: "editing from code" },
            handleCreateComponent: { operation: "creating a component" },
            handleDeleteComponents: { operation: "deleting components" },
            handleSetProperty: { operation: "setting a property" },
            handleSetProperties: { operation: "setting properties" },
            handleDeleteProperties: { operation: "deleting properties" },
            handleBatchUpdate: { operation: "updating multiple things" },
            handleUpdateApi: { operation: "updating an API" },
            handleDeleteApi: { operation: "deleting an API" },
            handleRenameApi: { operation: "renaming an API" },
            handleRenameElement: { operation: "renaming an element" },
        });
        const methods = [
            "handleCreateComponent",
            "handleDeleteComponents",
            "handleSetProperty",
            "handleSetProperties",
            "handleDeleteProperties",
            "handleBatchUpdate",
            "handleUpdateApi",
            "handleDeleteApi",
            "handleRenameApi",
            "handleRenameElement",
        ];
        addTracingToMethods(this, methods, tracer);
    }
    get appFilePath() {
        if (!this.rootDir) {
            throw new Error("Root directory not set");
        }
        return nodePath.join(this.rootDir, "App.tsx");
    }
    get rootScopeFilePath() {
        if (!this.rootDir) {
            throw new Error("Root directory not set");
        }
        return nodePath.join(this.rootDir, SCOPE_FILE);
    }
    // MARK: core setup/init
    async watch(watcher, rootPath) {
        const logger = getLogger();
        this.rootDir = rootPath;
        this.watcher = watcher;
        // APIs and pages are in the pages subfolder
        const fileNames = await readFiles(nodePath.join(rootPath));
        // this reads all files excluding ignore list in the root path. Should we read all files in pages and add root.tsx and App.tsx instead?
        const tsxFiles = fileNames.filter((f) => f.endsWith(".tsx") && !f.includes(`${getLegacyComponentsFolder()}/`));
        const yamlFiles = fileNames.filter((f) => f.endsWith(".yaml") && f.includes("/apis/"));
        logger.info(`scanning ${tsxFiles.length} code files`);
        logger.info(`scanning ${yamlFiles.length} api files`);
        const allFiles = [
            ...tsxFiles.map((f) => {
                return {
                    type: "tsx",
                    path: f,
                    content: readFile(f),
                };
            }),
            ...yamlFiles.map((f) => {
                return {
                    type: "api",
                    path: f,
                    content: getMergedApiContent(f),
                };
            }),
        ].flat();
        const contents = await Promise.allSettled(allFiles.map((f) => f.content));
        contents.forEach((result, i) => {
            if (result.status === "rejected") {
                logger.error(`Error getting content for file: ${allFiles[i]?.path}`, getErrorMeta(result.reason));
                return;
            }
            const content = result.value;
            if (!content)
                return;
            const file = allFiles[i];
            if (!file)
                return;
            const { type, path } = file;
            // We only want to load the typescript files here, before the source tracker is initialized
            // API setup needs to be deferred until we've parsed the scope files (which is done in the source tracker)
            if (type === "tsx" || type === "ts") {
                this.tsFiles[normalizePath(path)] = content;
            }
        });
        try {
            this.initializeSourceTracker();
        }
        catch (e) {
            logger.error("Error initializing source tracker", getErrorMeta(e));
        }
        contents.forEach((result, i) => {
            if (result.status === "rejected") {
                logger.error(`Error getting content for file: ${allFiles[i]?.path}`, getErrorMeta(result.reason));
                return;
            }
            const content = result.value;
            if (!content)
                return;
            const file = allFiles[i];
            if (!file)
                return;
            const { type, path } = file;
            if (type === "api") {
                this.updateInternalApiData(content, path);
            }
        });
        // Find and store the router file path for watching
        try {
            this.routerFilePath = await findRouterFile(rootPath);
            logger.info(`Router file found at: ${this.routerFilePath}`);
        }
        catch (e) {
            logger.error("No router file found", getErrorMeta(e));
            this.routerFilePath = null;
        }
        watcher.on("all", (event, filePath) => {
            if (event == "add" || event == "unlink") {
                void this.handleFileChange(event, filePath);
                return;
            }
            let handler = this.debouncedFileUpdateHandlers.get(filePath);
            if (!handler) {
                handler = debounce(() => {
                    void this.handleFileChange(event, filePath);
                }, 100, { leading: true, trailing: true, maxWait: 500 });
                this.debouncedFileUpdateHandlers.set(filePath, handler);
            }
            handler();
        });
    }
    initializeSourceTracker() {
        this.sourceTracker = new SourceTracker(this._tracer);
        this.sourceTracker.initializeSourceTracker({
            files: this.tsFiles,
        });
    }
    handleNonVisualChangeByDeletingIds(path, content) {
        if (!this.sourceTracker) {
            throw new Error("Source tracker not initialized");
        }
        return this.sourceTracker.handleNonVisualChangeByDeletingIds(path, content);
    }
    async getPropertyCode(itemId, propertyName) {
        const logger = getLogger();
        if (!this.sourceTracker) {
            throw new Error("Source tracker not initialized");
        }
        try {
            // Get the element location (cast itemId to SbElement type)
            const element = this.sourceTracker.getElementToLocation(itemId);
            if (!element) {
                logger.warn(`Element not found: ${itemId}`);
                return null;
            }
            // Get the JSX opening element
            let openingElement = null;
            if (element.isJSXElement()) {
                openingElement = element.get("openingElement");
            }
            else if (element.isJSXOpeningElement()) {
                openingElement = element;
            }
            if (!openingElement) {
                logger.warn(`Could not find opening element for: ${itemId}`);
                return null;
            }
            // Find the attribute with the specified property name
            const attributes = openingElement.get("attributes");
            for (const attr of attributes) {
                if (attr.isJSXAttribute()) {
                    const attrName = attr.get("name");
                    if (attrName.isJSXIdentifier() &&
                        attrName.node.name === propertyName) {
                        const value = attr.get("value");
                        // Handle different value types
                        if (value.isStringLiteral()) {
                            return value.node.value;
                        }
                        else if (value.isJSXExpressionContainer()) {
                            const expression = value.get("expression");
                            // Generate code from the expression
                            const code = generate(expression.node).code;
                            return code;
                        }
                    }
                }
            }
            logger.warn(`Property ${propertyName} not found on element ${itemId}`);
            return null;
        }
        catch (error) {
            logger.error(`Error getting property code for ${itemId}.${propertyName}`, getErrorMeta(error));
            return null;
        }
    }
    async getFileWithIds(filePath) {
        const logger = getLogger();
        if (!this.sourceTracker) {
            throw new Error("Source tracker not initialized");
        }
        const normalizedPath = normalizePath(filePath);
        const files = this.sourceTracker.getCurrentFiles();
        if (!files[normalizedPath]) {
            throw new Error("File not found in source tracker " + normalizedPath);
        }
        let ast = files[normalizedPath].ast;
        if (!ast) {
            throw new Error("No AST found for file in source tracker " + normalizedPath);
        }
        if (normalizedPath in this.tsFiles &&
            this.tsFiles[normalizedPath] &&
            files[normalizedPath].code !== this.tsFiles[normalizedPath]) {
            getLogger().error("File code mismatch in source tracker, this should never happen: " +
                filePath);
            await this.handleNonVisualChangeByDeletingIds(filePath, this.tsFiles[normalizedPath]);
            const newAst = this.sourceTracker.getCurrentFiles()[normalizedPath]?.ast;
            if (!newAst) {
                throw new Error("No AST found for file in source tracker after mismatch:" + filePath);
            }
            ast = newAst;
        }
        const clonedAst = t.cloneNode(ast, true, false);
        if (!clonedAst) {
            throw new Error("Failed to clone AST for file " + filePath);
        }
        const componentsManager = ComponentsManager.getInstance();
        const processedTransactions = this.getProcessedTransactionsWithNonce();
        const customImports = new Set();
        traverse(clonedAst, {
            Program: {
                exit(nodePath) {
                    if (isCustomBuildEnabled()) {
                        addLegacyCustomComponentVariables(nodePath, customImports);
                    }
                    // we want to inject the component name into the file so we can use it for hot reloading purposes
                    const name = componentsManager.getComponentName(filePath);
                    if (name) {
                        nodePath.pushContainer("body", t.variableDeclaration("const", [
                            t.variableDeclarator(t.identifier("componentName"), t.stringLiteral(name)),
                        ]));
                    }
                },
            },
            ImportDeclaration(path) {
                if (isCustomBuildEnabled()) {
                    // when we see a custom component, we want to track the import and modify it to point
                    // to the built source
                    modifyLegacyCustomComponentImports(path, customImports);
                }
            },
            JSXElement(path) {
                if (isCustomBuildEnabled()) {
                    // based on the imports we see, we want to modify the JSXElement that uses the custom component
                    modifyLegacyCustomComponentElements(path, customImports);
                }
            },
            ReturnStatement(path) {
                const argument = path.get("argument");
                if (!argument.isJSXElement()) {
                    return;
                }
                const openingElement = argument.get("openingElement");
                if (!openingElement.isJSXOpeningElement()) {
                    return;
                }
                // we are checking here if the return statement is either the top level return statement of a function or
                // inside some inner function in the component (i.e. in a map)
                const isProgramParentsScope = path.scope.parent === path.scope.getProgramParent();
                if (isProgramParentsScope) {
                    openingElement.pushContainer("attributes", makeJSXAttribute(ROOT_WIDGET_ATTRIBUTE, "true"));
                    openingElement.pushContainer("attributes", generateJSXAttribute(PROCESSED_TRANSACTIONS_ATTRIBUTE, {
                        value: processedTransactions,
                        type: "EXPRESSION",
                        __info: true,
                    }));
                }
            },
            JSXOpeningElement(path) {
                const elementId = getSbElementId(path.node);
                if (elementId) {
                    path.pushContainer("attributes", makeJSXAttribute(SOURCE_ID_ATTRIBUTE, elementId));
                }
            },
            ObjectProperty(path) {
                const elementId = getSbElementId(path.node);
                if (!elementId) {
                    return;
                }
                path.traverse({
                    ObjectExpression(path) {
                        path.pushContainer("properties", t.objectProperty(t.identifier("id"), t.stringLiteral(elementId)));
                        path.skip();
                    },
                });
            },
        });
        try {
            const result = generate(clonedAst);
            return result.code;
        }
        catch (e) {
            logger.error("Error generating file with ids", getErrorMeta(e));
            return null;
        }
    }
    // MARK: file change handling
    handleFileChange = async (event, filePath) => {
        const logger = getLogger();
        logger.info(`File changed: ${filePath}, event: ${event}`);
        const rootPath = this.rootDir;
        if (!rootPath) {
            throw new Error("Root directory not set");
        }
        // Skip directory events
        if (event === "addDir" || event === "unlinkDir") {
            return;
        }
        const fileType = SUPPORTED_FILETYPES.find((f) => filePath.endsWith(f.extension));
        // Normalize both paths for comparison (Windows compatibility)
        const normalizedFilePath = normalizePath(filePath);
        const normalizedRootPath = normalizePath(rootPath);
        // Only handle files we care about and that are in our root path
        if (!fileType || !normalizedFilePath.startsWith(normalizedRootPath)) {
            return;
        }
        // Queue the operation based on the event type
        switch (event) {
            case "add": {
                const file = await readFile(normalizedFilePath);
                if (typeof file !== "string")
                    return;
                switch (fileType.type) {
                    case "api":
                    case "python-api-step":
                    case "js-api-step": {
                        await this.processApiFileUpdates(normalizedFilePath, fileType);
                        break;
                    }
                    case "ts":
                    case "tsx":
                        if (!(normalizedFilePath in this.tsFiles)) {
                            this.tsFiles[normalizedFilePath] = file;
                            await this.handleNonVisualChangeByDeletingIds(normalizedFilePath, file);
                            this.emit("fileChanged", normalizedFilePath, file, true);
                            return;
                        }
                }
                break;
            }
            case "change": {
                const fileType = SUPPORTED_FILETYPES.find((f) => normalizedFilePath.endsWith(f.extension));
                if (!fileType || !normalizedFilePath.startsWith(rootPath)) {
                    return;
                }
                const data = await readFile(normalizedFilePath);
                if (typeof data !== "string")
                    return;
                // Handle router file changes
                if (this.routerFilePath && normalizedFilePath === this.routerFilePath) {
                    logger.info("Router file changed, emitting routerChange event");
                    this.emit("routerChange", normalizedFilePath);
                    return;
                }
                switch (fileType.type) {
                    case "ts":
                    case "tsx":
                    case "scope":
                        {
                            if (!(normalizedFilePath in this.tsFiles &&
                                this.tsFiles[normalizedFilePath] === data)) {
                                logger.info(`File changed: ${normalizedFilePath} updating AST tracker`);
                                this.tsFiles[normalizedFilePath] = data;
                                // only update the source tracker if the file is different
                                await this.handleNonVisualChangeByDeletingIds(normalizedFilePath, data);
                                this.emit("fileChanged", normalizedFilePath, data, true);
                            }
                            else {
                                logger.info(`File unchanged from last tracked state: ${normalizedFilePath}`);
                                this.emit("fileChanged", normalizedFilePath, data, false);
                                this.sourceTracker?.updateFileContents(normalizedFilePath, data);
                            }
                        }
                        break;
                    case "api":
                    case "python-api-step":
                    case "js-api-step":
                        {
                            await this.processApiFileUpdates(normalizedFilePath, fileType);
                        }
                        break;
                }
                break;
            }
            case "unlink": {
                if (normalizedFilePath in this.tsFiles) {
                    await this.deleteTsFile(normalizedFilePath);
                    if (this.routerFilePath &&
                        normalizedFilePath === this.routerFilePath) {
                        logger.warn("Router file was deleted, attempting to find new router file");
                        try {
                            this.routerFilePath = await findRouterFile(rootPath);
                            logger.info(`New router file found at: ${this.routerFilePath}`);
                            this.emit("routerChange", this.routerFilePath);
                        }
                        catch {
                            this.routerFilePath = null;
                            const error = new Error("Router file was deleted and no replacement could be found.");
                            logger.error("Router file missing", getErrorMeta(error));
                        }
                    }
                }
                else if (normalizedFilePath in this.apiFiles) {
                    await this.removeApiData(normalizedFilePath);
                }
                break;
            }
        }
    };
    async deleteTsFile(filePath) {
        const normalizedPath = normalizePath(filePath);
        delete this.tsFiles[normalizedPath];
        this.sourceTracker?.removeFile(normalizedPath);
        if (isPageFilePath(normalizedPath)) {
            this.emit("deletePage", normalizedPath);
        }
    }
    getTsFilePaths() {
        return Object.keys(this.tsFiles);
    }
    getSourceTracker() {
        return this.sourceTracker;
    }
    /**
     * Set the template renderer for YAML→TS transformations.
     * Called by AiService after initialization.
     */
    setTemplateRenderer(templateRenderer) {
        this.templateRenderer = templateRenderer;
    }
    // MARK: fs read/write
    async writeFile(path, content, kind) {
        getLogger().info(`Writing file: ${path}, kind: ${kind ?? "other"}`);
        switch (kind) {
            case "ts": {
                // happens eagerly regardless of error - possible desync
                const normalizedPath = normalizePath(path);
                this.tsFiles[normalizedPath] = content;
                // TODO(george): as an optimization, we could update the memory snapshot of the file that SyncService is holding
                await this.fsOperationQueue.enqueue(async () => {
                    const dir = path.substring(0, path.lastIndexOf("/"));
                    if (dir) {
                        await fs.mkdir(dir, { recursive: true });
                    }
                    await fs.writeFile(path, content);
                });
                break;
            }
            case "api": {
                const normalizedPath = normalizePath(path);
                const currentApiFile = this.apiFiles[normalizedPath];
                const apiPb = yaml.parse(content);
                // We don't need ID, organization, or trigger data in code-mode APIs
                if (apiPb.metadata) {
                    delete apiPb.metadata.id;
                    delete apiPb.metadata.organization;
                }
                if (apiPb.trigger) {
                    delete apiPb.trigger;
                }
                const stepPathMap = currentApiFile?.stepPathMap ?? {};
                const scopeId = getScopeIdFromName("App");
                // Update in-memory state before writing to disk (similar to TS files)
                // This allows the file watcher to detect no change and skip duplicate events
                this.apiFiles[normalizedPath] = {
                    apiPb: yaml.parse(JSON.stringify(apiPb)),
                    stepPathMap,
                    scopeId,
                };
                // TODO(george): as an optimization, we could update the memory snapshot of the file that SyncService is holding
                const apiDir = nodePath.dirname(path);
                await this.fsOperationQueue.enqueue(async () => {
                    await writeApiFiles({ apiPb }, "api", apiDir, false, [], [], { extractLargeSourceFiles: true, minLinesForExtraction: 1 }, new Set(Object.keys(this.apiFiles)), stepPathMap);
                });
                break;
            }
            default: {
                // TODO(george): as an optimization, we could update the memory snapshot of the file that SyncService is holding
                await this.fsOperationQueue.enqueue(async () => {
                    const dir = path.substring(0, path.lastIndexOf("/"));
                    if (dir) {
                        await fs.mkdir(dir, { recursive: true });
                    }
                    await fs.writeFile(path, content);
                });
            }
        }
    }
    readFile(path) {
        return this.tsFiles[normalizePath(path)];
    }
    async setFileContents(path, contents) {
        const logger = getLogger();
        logger.info(`Setting file contents: ${path}`);
        // Determine file kind
        const kind = isApiFilePath(path)
            ? "api"
            : path.endsWith(".ts") || path.endsWith(".tsx")
                ? "ts"
                : undefined;
        await this.writeFile(path, contents, kind);
        // Emit fileChanged event for TypeScript files
        if (kind === "ts") {
            this.emit("fileChanged", path, contents, true);
        }
    }
    async writeChanges(changes, callback) {
        return Promise.all(changes.map(async ({ fileName, source, kind }) => {
            await this.writeFile(fileName, source, kind);
            return callback?.(fileName, source);
        }));
    }
    getLocalBindingEntities(path) {
        const logger = getLogger();
        const normalizedPath = normalizePath(path);
        const files = this.sourceTracker?.getCurrentFiles();
        if (!files || !files[normalizedPath]) {
            logger.warn(`File not found in source tracker for local binding entities: ${normalizedPath}`);
            return [];
        }
        const ast = files[normalizedPath].ast;
        if (!ast) {
            logger.warn(`No AST found for file in source tracker for local binding entities: ${normalizedPath}`);
            return [];
        }
        const localBindingEntities = new Set();
        traverse(ast, {
            JSXOpeningElement(path) {
                const elementId = getSbElementId(path.node);
                if (!elementId) {
                    return;
                }
                const hasLocalBinding = doesNodePathHaveBinding(path);
                if (hasLocalBinding) {
                    localBindingEntities.add(elementId);
                }
            },
        });
        return Array.from(localBindingEntities);
    }
    getPageRootNodes(filePath) {
        const scopeFilePath = normalizePath(nodePath.join(nodePath.dirname(filePath), "index.tsx"));
        return this.getRootNodesForFile(scopeFilePath);
    }
    getAppRootNodes() {
        return this.getRootNodesForFile(this.appFilePath);
    }
    async collectAllImports(filePath, allFiles, visited = new Set()) {
        const importedFiles = {};
        if (visited.has(filePath)) {
            return importedFiles; // Avoid circular dependencies
        }
        visited.add(filePath);
        const currentFile = allFiles[filePath];
        if (!currentFile) {
            return importedFiles;
        }
        for (const [_name, localPath] of Object.entries(currentFile.imports)) {
            let resolvedPath = await getFullImportPath(localPath);
            // Fallback: manually resolve relative paths if Vite resolver fails
            if (!resolvedPath && localPath.startsWith(".")) {
                const currentDir = nodePath.dirname(filePath);
                const potentialPath = nodePath.resolve(currentDir, localPath);
                // Try with different extensions
                const extensions = [
                    "/index.tsx",
                    "/index.ts",
                    ".tsx",
                    ".ts",
                    ".jsx",
                    ".js",
                ];
                for (const ext of extensions) {
                    const testPath = potentialPath + ext;
                    if (allFiles[testPath]) {
                        resolvedPath = testPath;
                        break;
                    }
                }
            }
            if (resolvedPath &&
                allFiles[resolvedPath] &&
                !importedFiles[resolvedPath]) {
                importedFiles[resolvedPath] = allFiles[resolvedPath];
                // Recursively collect imports from this file
                const nestedImports = await this.collectAllImports(resolvedPath, allFiles, visited);
                Object.assign(importedFiles, nestedImports);
            }
        }
        return importedFiles;
    }
    async getRootNodesForFile(filePath) {
        const normalizedPath = normalizePath(filePath);
        const currentFile = this.sourceTracker?.getCurrentFiles()[normalizedPath];
        if (!currentFile) {
            return undefined;
        }
        const allFiles = this.sourceTracker?.getCurrentFiles() ?? {};
        // Collect all imported files recursively (including transitive imports)
        const importedNamesToFiles = await this.collectAllImports(normalizedPath, allFiles);
        // Get roots from imported files (custom components)
        const importedRoots = Object.entries(importedNamesToFiles).flatMap(([filePath, file]) => getRootNodes(filePath, file));
        // Get roots from the current file
        const roots = getRootNodes(normalizedPath, currentFile);
        return [...roots, ...importedRoots];
    }
    getScope(filePath, fallback) {
        // Since we no longer use scope.ts files, generate scope info from file path. Pages retain page scope, everything else is App.
        const normalizedPath = normalizePath(filePath);
        const scopeName = getScopeNameFromPath(normalizedPath);
        if (!scopeName) {
            if (fallback) {
                return {
                    entities: {},
                    scopeName: "App",
                    scopeId: getScopeIdFromName("App"),
                };
            }
            return undefined;
        }
        return {
            entities: {},
            scopeName,
            scopeId: getScopeIdFromName(scopeName),
        };
    }
    // MARK: transaction handling
    flushTransactions = () => {
        // TODO do something more sophisticated than this.
        this.processedTransactions = this.processedTransactions.slice(-20);
        this.processedTransactions.push(...Array.from(this.pendingTransactions));
        this.pendingTransactions.clear();
    };
    trackTransaction = (transactionId) => {
        if (!transactionId) {
            return;
        }
        this.pendingTransactions.add(transactionId);
    };
    // Until we have a sophisticated way to track ALL operations, including non-UI initiated ops, we will use a nonce
    // TODO https://github.com/superblocksteam/superblocks/pull/11788
    getProcessedTransactionsWithNonce() {
        const nonce = `t-${this.transactionNonce++}`;
        return this.processedTransactions.concat(nonce);
    }
    // MARK: editor operations
    get routeChange() {
        return this.routeChangesQueue.shift();
    }
    handleCreateComponent = async (payload, writeFile = true) => {
        this.trackTransaction(payload.transaction?.id);
        const sourceId = this.sourceTracker?.addElement(payload);
        if (writeFile) {
            const changes = (await this.sourceTracker?.getAndFlushChanges()) ?? [];
            await this.writeChanges(changes ?? [], (fileName) => {
                this.emit("createComponent", fileName);
            });
            this.flushTransactions();
        }
        return sourceId;
    };
    handleDeleteComponents = async (payload, writeFile = true) => {
        const { elements, transaction } = payload;
        this.trackTransaction(transaction?.id);
        for (const element of elements) {
            this.sourceTracker?.deleteElement({
                source: element.source,
                scopeName: element.scopeName,
            });
        }
        if (writeFile) {
            const changes = (await this.sourceTracker?.getAndFlushChanges()) ?? [];
            await this.writeChanges(changes ?? [], (fileName) => {
                this.emit("deleteComponents", fileName, elements.map((el) => el.source.id));
            });
            this.flushTransactions();
        }
    };
    handleSetProperty = async (payload, writeFile = true) => {
        const { element: { source }, property, value, transaction, } = payload;
        this.trackTransaction(transaction?.id);
        this.sourceTracker?.setProperty({
            source,
            property,
            info: value,
        });
        if (writeFile) {
            const changes = (await this.sourceTracker?.getAndFlushChanges()) ?? [];
            await this.writeChanges(changes ?? []);
            this.flushTransactions();
            this.emit("setProperty", payload);
        }
    };
    handleSetProperties = async (payload, writeFile = true) => {
        const { element: { source }, properties, transaction, } = payload;
        this.trackTransaction(transaction?.id);
        this.sourceTracker?.setProperties({
            source,
            changes: properties,
        });
        if (writeFile) {
            const changes = (await this.sourceTracker?.getAndFlushChanges()) ?? [];
            await this.writeChanges(changes ?? []);
            this.flushTransactions();
            this.emit("setProperties", payload);
        }
    };
    handleDeleteProperties = async (payload, writeFile = true) => {
        this.trackTransaction(payload.transaction?.id);
        await this.sourceTracker?.deleteProperties({
            source: payload.element.source,
            properties: payload.properties,
        });
        if (writeFile) {
            const changes = (await this.sourceTracker?.getAndFlushChanges()) ?? [];
            await this.writeChanges(changes ?? []);
            this.emit("deleteProperties", payload);
            this.flushTransactions();
        }
    };
    handleBatchUpdate = async (payload) => {
        const { updates, transaction } = payload;
        this.trackTransaction(transaction?.id);
        const returnValues = [];
        for (const update of updates) {
            switch (update.type) {
                case "editor:setProperty":
                    returnValues.push(await this.handleSetProperty(update.payload, false));
                    break;
                case "editor:setProperties":
                    returnValues.push(await this.handleSetProperties(update.payload, false));
                    break;
                case "editor:createComponent":
                    returnValues.push(await this.handleCreateComponent(update.payload, false));
                    break;
                case "editor:deleteProperties":
                    returnValues.push(await this.handleDeleteProperties(update.payload, false));
                    break;
                case "editor:deleteComponents":
                    returnValues.push(await this.handleDeleteComponents(update.payload, false));
                    break;
            }
        }
        const changes = (await this.sourceTracker?.getAndFlushChanges()) ?? [];
        await this.writeChanges(changes ?? []);
        this.emit("batchUpdate", Array.from(new Set(changes.map((c) => c.fileName))), payload);
        this.flushTransactions();
        return returnValues;
    };
    // MARK: rename operations
    handleRenameElement = async (payload) => {
        if (payload.kind === "component") {
            return this.handleRenameComponent(payload);
        }
    };
    handleRenameComponent = async (_payload) => {
        // there are no component names anymore, so there is nothing to do here
        // TODO: remove this method entirely
    };
    getRelativeRoutePath(filePath) {
        if (!this.rootDir) {
            throw new Error("Root directory not set");
        }
        // no leading slash, normalize to forward slashes for routes (Windows compatibility)
        return normalizePath(nodePath.relative(nodePath.join(this.rootDir, PAGES_DIRECTORY), filePath));
    }
    // MARK: API operations
    handleUpdateApi = async (payload) => {
        const apiName = payload.api.apiPb.metadata.name;
        if (!apiName) {
            throw new Error("API name is not set");
        }
        getLogger().info("Handling API update for " + apiName);
        const { api } = payload;
        if (!this.sourceTracker) {
            throw new Error("Source tracker not initialized");
        }
        if (!this.rootDir) {
            throw new Error("Root directory not set");
        }
        const apiFilePath = getApiFilePath(this.rootDir, apiName);
        const normalizedApiFilePath = normalizePath(apiFilePath);
        const apiDir = nodePath.dirname(normalizedApiFilePath);
        try {
            const stats = await fs.stat(apiDir);
            if (!stats.isDirectory()) {
                await this.fsOperationQueue.enqueue(async () => await fs.mkdir(apiDir, { recursive: true }));
            }
        }
        catch {
            await this.fsOperationQueue.enqueue(async () => await fs.mkdir(apiDir, { recursive: true }));
        }
        await this.writeFile(apiFilePath, yaml.stringify(api.apiPb), "api");
        const generationNumber = this.generationNumberSequence.next();
        const scopeId = getScopeIdFromName("App");
        const apiDef = this.createClientApi(api);
        this.emit("apiUpdate", { api: apiDef, scopeId });
        return { api: apiDef, scopeId, generationNumber };
    };
    handleDeleteApi = async (payload) => {
        const logger = getLogger();
        const { apis } = payload;
        if (!this.rootDir) {
            throw new Error("Root directory not set");
        }
        const rootDir = this.rootDir;
        const deletedApis = [];
        for (const { apiName } of apis) {
            const apiFilePath = getApiFilePath(rootDir, apiName);
            const normalizedApiFilePath = normalizePath(apiFilePath);
            const api = this.apiFiles[normalizedApiFilePath];
            if (!api || !this.sourceTracker) {
                continue;
            }
            const apiDir = nodePath.dirname(apiFilePath);
            try {
                await this.fsOperationQueue.enqueue(async () => {
                    const stats = await fs.stat(apiDir);
                    if (stats.isDirectory()) {
                        await fs.rmdir(apiDir, { recursive: true });
                    }
                });
                delete this.apiFiles[normalizedApiFilePath];
                const scopeId = this.sourceTracker.deleteApi({
                    apiName,
                });
                deletedApis.push({
                    apiName,
                    scopeId,
                });
            }
            catch (e) {
                logger.warn(`Could not delete api ${apiFilePath}`, e);
            }
        }
        const changes = (await this.sourceTracker?.getAndFlushChanges()) ?? [];
        await this.writeChanges(changes);
        this.emit("apiDelete", {
            apis: deletedApis.filter((api) => api !== undefined),
        });
        return { deletedApis };
    };
    handleRenameApi = async (payload) => {
        const { oldName, newName } = payload;
        if (!this.rootDir) {
            throw new Error("Root directory not set");
        }
        const existingApiFolder = nodePath.join(this.rootDir, "apis", oldName);
        const newApiFolder = nodePath.join(this.rootDir, "apis", newName);
        const apiFilePath = nodePath.join(existingApiFolder, "api.yaml");
        const normalizedApiFilePath = normalizePath(apiFilePath);
        const apiDef = this.apiFiles[normalizedApiFilePath];
        if (!apiDef) {
            throw new Error(`API ${oldName} not found`);
        }
        const otherAPIs = structuredClone(Object.keys(this.apiFiles)
            .filter((path) => path !== normalizedApiFilePath)
            .map((path) => ({
            api: { apiPb: this.apiFiles[path]?.apiPb },
            filePath: path,
        }))
            .filter((api) => !!api.api));
        const newApiFolderExists = await fs.stat(newApiFolder).catch(() => false);
        if (newApiFolderExists) {
            throw new Error(`API ${newName} already exists`);
        }
        this.watcher?.unwatch(existingApiFolder);
        this.watcher?.unwatch(newApiFolder);
        apiDef.apiPb.metadata.name = newName;
        await fs.rename(existingApiFolder, newApiFolder);
        const scopeId = getScopeIdFromName("App");
        delete this.apiFiles[normalizedApiFilePath];
        // not waiting because we are queueing the write further down
        void this.writeFile(nodePath.join(newApiFolder, "api.yaml"), yaml.stringify(apiDef.apiPb), "api");
        this.watcher?.add(existingApiFolder);
        this.watcher?.add(newApiFolder);
        const changes = (await this.sourceTracker?.getAndFlushChanges()) ?? [];
        await this.writeChanges(changes);
        this.emit("apiManualUpdate", {
            api: this.createClientApi(apiDef),
            oldName,
            scopeId,
        }, true);
        await this.renameManager.renameEntityInApis({
            oldName,
            newName,
            apis: otherAPIs,
        });
        // only save the APIs that have changed
        await Promise.all(otherAPIs.map(async ({ api, filePath }) => {
            if (isEqual(api?.apiPb, this.apiFiles[filePath]?.apiPb)) {
                return Promise.resolve();
            }
            await this.writeFile(filePath, yaml.stringify(api?.apiPb), "api");
            this.emit("apiManualUpdate", {
                api: this.createClientApi(api),
                scopeId,
            }, true);
        }));
    };
    async removeApiData(filePath) {
        const normalizedPath = normalizePath(filePath);
        const api = this.apiFiles[normalizedPath];
        if (!api) {
            return;
        }
        delete this.apiFiles[normalizedPath];
        this.sourceTracker?.deleteApi({
            apiName: api.apiPb.metadata.name,
        });
        const changes = (await this.sourceTracker?.getAndFlushChanges()) ?? [];
        await this.writeChanges(changes);
        const scopeId = getScopeIdFromName("App");
        this.emit("apiManualDelete", {
            api: {
                id: api.apiPb.metadata.name,
                apiName: api.apiPb.metadata.name,
                scopeId,
            },
        });
    }
    getApiFiles() {
        return Object.keys(this.apiFiles).reduce((acc, key) => {
            if (!this.apiFiles[key]) {
                return acc;
            }
            acc[key] = {
                api: this.createClientApi(this.apiFiles[key]),
                scopeId: this.apiFiles[key].scopeId,
            };
            return acc;
        }, {});
    }
    // Utilities for converting server API format to Client API format
    // We internally save the API as the server does, but we return should always return it
    // to the client with an id equal to the API name
    createClientApi(api) {
        return {
            pageName: api.pageName,
            apiPb: {
                ...api.apiPb,
                metadata: {
                    ...api.apiPb.metadata,
                    id: api.apiPb.metadata.name,
                },
            },
        };
    }
    addApiToScope = async (api) => {
        if (!this.sourceTracker) {
            throw new Error("Source tracker not initialized");
        }
        // We want to add the API entity to our scope, but we do not want to emit entity events, because
        // the API update event handles this particular side effect.
        const scopeId = await this.sourceTracker.addApi({
            apiName: api.apiPb.metadata.name,
        });
        const changes = (await this.sourceTracker?.getAndFlushChanges()) ?? [];
        await this.writeChanges(changes);
        return scopeId;
    };
    async processApiFileUpdates(filePath, fileType) {
        let yamlPath = filePath;
        if (fileType.type === "python-api-step" ||
            fileType.type === "js-api-step") {
            const apiFolderPath = nodePath.dirname(filePath);
            yamlPath = nodePath.join(apiFolderPath, "api.yaml");
        }
        const normalizedYamlPath = normalizePath(yamlPath);
        const logger = getLogger();
        logger.info(`Processing API file update: ${yamlPath}`);
        try {
            const apiContent = await getMergedApiContent(yamlPath);
            logger.info(`API content read successfully: ${yamlPath}`);
            if (!apiContent) {
                return;
            }
            logger.info(`API structure is valid: ${yamlPath}`);
            const parsedData = { apiPb: apiContent?.api };
            if (normalizedYamlPath in this.apiFiles &&
                isEqual(this.apiFiles[normalizedYamlPath]?.apiPb, parsedData.apiPb)) {
                logger.info(`No changes detected in API: ${normalizedYamlPath}`);
                return;
            }
            const { updatedApi, isNewApi } = this.updateInternalApiData({
                api: parsedData.apiPb,
                stepPathMap: apiContent.stepPathMap,
            }, yamlPath);
            logger.info(`API changed: ${yamlPath}`);
            const scopeId = getScopeIdFromName("App");
            this.emit("apiManualUpdate", {
                api: this.createClientApi(updatedApi),
                scopeId,
            }, isNewApi);
        }
        catch (error) {
            logger.error(`Error updating API: ${yamlPath}, error: ${error}`);
        }
    }
    updateInternalApiData = (content, path) => {
        if (!this.rootDir) {
            throw new Error("Root directory not set");
        }
        const { api: apiContents, stepPathMap } = content;
        const scopeId = getScopeIdFromName("App");
        const updatedApi = {
            apiPb: yaml.parse(JSON.stringify(apiContents)),
            stepPathMap,
            scopeId,
        };
        const isNewApi = !this.apiFiles[path];
        this.apiFiles[path] = updatedApi;
        return { updatedApi, isNewApi };
    };
    renameIdentifierInApis = async ({ elementId, oldName, newName, parentBinding, }) => {
        const apisInScope = structuredClone(this.getApisInScope(elementId));
        await this.renameManager.renameEntityInApis({
            oldName,
            newName,
            parentBinding,
            apis: apisInScope,
        });
        // only save the APIs that have changed
        await Promise.all(apisInScope.map(({ api, filePath }) => {
            if (isEqual(api?.apiPb, this.apiFiles[filePath]?.apiPb)) {
                return Promise.resolve();
            }
            return this.writeFile(filePath, yaml.stringify(api?.apiPb), "api");
        }));
    };
    getApisInScope = (_elementId) => {
        // All APIs are now at app-level scope
        return Object.entries(this.apiFiles).map(([filePath, api]) => ({
            api,
            filePath,
        }));
    };
}
// Add new mock implementation
export class MockFileSyncManager extends FileSystemManager {
    tsFiles = {};
    apiFiles = {};
    async watch(_watcher, _path) {
        // Mock implementation doesn't actually watch files
    }
    async writeFile(path, content, kind) {
        await this.fsOperationQueue.enqueue(async () => {
            const normalizedPath = normalizePath(path);
            if (kind === "ts") {
                this.tsFiles[normalizedPath] = content;
            }
            else {
                this.apiFiles[normalizedPath] = {
                    apiPb: yaml.parse(content),
                    stepPathMap: {},
                    scopeId: getScopeIdFromName("App"),
                };
            }
            // Notify subscribers
            this.emit("fileChanged", normalizedPath, content, true);
        });
    }
    // Helper methods for testing
    mockFileChange(path, content) {
        const normalizedPath = normalizePath(path);
        this.tsFiles[normalizedPath] = content;
        this.emit("fileChanged", normalizedPath, content, true);
    }
    handleUpdateApi = async (payload) => {
        const apiName = payload.api.apiPb.metadata.name;
        if (!this.rootDir) {
            throw new Error("Root directory not set");
        }
        const apiFilePath = getApiFilePath(this.rootDir, apiName);
        const normalizedApiFilePath = normalizePath(apiFilePath);
        this.apiFiles[normalizedApiFilePath] = {
            ...payload.api,
            stepPathMap: {},
            scopeId: payload.scopeId,
        };
        const scopeId = getScopeIdFromName("App");
        const generationNumber = this.generationNumberSequence.next();
        this.emit("apiUpdate", {
            api: payload.api,
            scopeId,
        });
        return {
            api: payload.api,
            generationNumber,
            scopeId,
        };
    };
}
async function readFile(path) {
    try {
        // in order for try-catch to work, we need to intentionally await the readFile call,
        // otherwise the error won't be be caught
        // see: https://github.com/nodejs/node/issues/51894#issuecomment-1974017737
        return await fs.readFile(path, "utf-8");
    }
    catch (e) {
        getLogger().error(`error reading file: ${path}`, getErrorMeta(e));
        return null;
    }
}
async function readFiles(dir) {
    const files = await glob("**/*.{tsx,yaml,ts}", {
        cwd: dir,
        absolute: true,
        ignore: [
            "node_modules/**",
            "dist/**",
            "build/**",
            ".superblocks/**",
            `./${getLegacyComponentsFolder()}/**`,
        ],
    });
    // Normalize paths to use forward slashes on all platforms (Windows returns backslashes)
    return files.map((file) => normalizePath(file));
}
const getMergedApiContent = async (filePath) => {
    return readAppApiYamlFile(nodePath.dirname(filePath));
};
//# sourceMappingURL=file-system-manager.js.map