"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ExtensionsEmulator = void 0;
const clc = require("colorette");
const spawn = require("cross-spawn");
const fs = require("fs-extra");
const os = require("os");
const path = require("path");
const Table = require("cli-table");
const planner = require("../deploy/extensions/planner");
const ensureApiEnabled_1 = require("../ensureApiEnabled");
const error_1 = require("../error");
const optionsHelper_1 = require("../extensions/emulator/optionsHelper");
const refs_1 = require("../extensions/refs");
const shortenUrl_1 = require("../shortenUrl");
const constants_1 = require("./constants");
const download_1 = require("./download");
const emulatorLogger_1 = require("./emulatorLogger");
const validation_1 = require("./extensions/validation");
const registry_1 = require("./registry");
const types_1 = require("./types");
class ExtensionsEmulator {
    constructor(args) {
        this.want = [];
        this.backends = [];
        this.logger = emulatorLogger_1.EmulatorLogger.forEmulator(types_1.Emulators.EXTENSIONS);
        this.pendingDownloads = new Map();
        this.args = args;
    }
    start() {
        this.logger.logLabeled("DEBUG", "Extensions", "Started Extensions emulator, this is a noop.");
        return Promise.resolve();
    }
    stop() {
        this.logger.logLabeled("DEBUG", "Extensions", "Stopping Extensions emulator, this is a noop.");
        return Promise.resolve();
    }
    connect() {
        this.logger.logLabeled("DEBUG", "Extensions", "Connecting Extensions emulator, this is a noop.");
        return Promise.resolve();
    }
    getInfo() {
        const functionsEmulator = registry_1.EmulatorRegistry.get(types_1.Emulators.FUNCTIONS);
        if (!functionsEmulator) {
            throw new error_1.FirebaseError("Extensions Emulator is running but Functions emulator is not. This should never happen.");
        }
        return Object.assign(Object.assign({}, functionsEmulator.getInfo()), { name: this.getName() });
    }
    getName() {
        return types_1.Emulators.EXTENSIONS;
    }
    async readManifest() {
        var _a;
        this.want = await planner.want({
            projectId: this.args.projectId,
            projectNumber: this.args.projectNumber,
            aliases: (_a = this.args.aliases) !== null && _a !== void 0 ? _a : [],
            projectDir: this.args.projectDir,
            extensions: this.args.extensions,
            emulatorMode: true,
        });
    }
    async ensureSourceCode(instance) {
        if (instance.localPath) {
            if (!this.hasValidSource({ path: instance.localPath, extTarget: instance.localPath })) {
                throw new error_1.FirebaseError(`Tried to emulate local extension at ${instance.localPath}, but it was missing required files.`);
            }
            return path.resolve(instance.localPath);
        }
        else if (instance.ref) {
            const ref = (0, refs_1.toExtensionVersionRef)(instance.ref);
            const cacheDir = process.env.FIREBASE_EXTENSIONS_CACHE_PATH ||
                path.join(os.homedir(), ".cache", "firebase", "extensions");
            const sourceCodePath = path.join(cacheDir, ref);
            if (this.pendingDownloads.get(ref)) {
                await this.pendingDownloads.get(ref);
            }
            if (!this.hasValidSource({ path: sourceCodePath, extTarget: ref })) {
                const promise = this.downloadSource(instance, ref, sourceCodePath);
                this.pendingDownloads.set(ref, promise);
                await promise;
            }
            return sourceCodePath;
        }
        else {
            throw new error_1.FirebaseError("Tried to emulate an extension instance without a ref or localPath. This should never happen.");
        }
    }
    async downloadSource(instance, ref, sourceCodePath) {
        const extensionVersion = await planner.getExtensionVersion(instance);
        await (0, download_1.downloadExtensionVersion)(ref, extensionVersion.sourceDownloadUri, sourceCodePath);
        this.installAndBuildSourceCode(sourceCodePath);
    }
    hasValidSource(args) {
        const requiredFiles = [
            "./extension.yaml",
            "./functions/package.json",
            "./functions/node_modules",
        ];
        if (!fs.existsSync(args.path)) {
            return false;
        }
        for (const requiredFile of requiredFiles) {
            const f = path.join(args.path, requiredFile);
            if (!fs.existsSync(f)) {
                this.logger.logLabeled("BULLET", "extensions", `Detected invalid source code for ${args.extTarget}, expected to find ${f}`);
                return false;
            }
        }
        this.logger.logLabeled("DEBUG", "extensions", `Source code valid for ${args.extTarget}`);
        return true;
    }
    installAndBuildSourceCode(sourceCodePath) {
        this.logger.logLabeled("DEBUG", "Extensions", `Running "npm install" for ${sourceCodePath}`);
        const functionsDirectory = path.resolve(sourceCodePath, "functions");
        const npmInstall = spawn.sync("npm", ["install"], {
            encoding: "utf8",
            cwd: functionsDirectory,
        });
        if (npmInstall.error) {
            throw npmInstall.error;
        }
        this.logger.logLabeled("DEBUG", "Extensions", `Finished "npm install" for ${sourceCodePath}`);
        this.logger.logLabeled("DEBUG", "Extensions", `Running "npm run gcp-build" for ${sourceCodePath}`);
        const npmRunGCPBuild = spawn.sync("npm", ["run", "gcp-build"], {
            encoding: "utf8",
            cwd: functionsDirectory,
        });
        if (npmRunGCPBuild.error) {
            throw npmRunGCPBuild.error;
        }
        this.logger.logLabeled("DEBUG", "Extensions", `Finished "npm run gcp-build" for ${sourceCodePath}`);
    }
    async getExtensionBackends() {
        await this.readManifest();
        await this.checkAndWarnAPIs(this.want);
        this.backends = await Promise.all(this.want.map((i) => {
            return this.toEmulatableBackend(i);
        }));
        return this.backends;
    }
    async toEmulatableBackend(instance) {
        const extensionDir = await this.ensureSourceCode(instance);
        const functionsDir = path.join(extensionDir, "functions");
        const env = Object.assign(this.autoPopulatedParams(instance), instance.params);
        const { extensionTriggers, runtime, nonSecretEnv, secretEnvVariables } = await (0, optionsHelper_1.getExtensionFunctionInfo)(instance, env);
        const emulatableBackend = {
            functionsDir,
            runtime,
            bin: process.execPath,
            env: nonSecretEnv,
            codebase: instance.instanceId,
            secretEnv: secretEnvVariables,
            predefinedTriggers: extensionTriggers,
            extensionInstanceId: instance.instanceId,
        };
        if (instance.ref) {
            emulatableBackend.extension = await planner.getExtension(instance);
            emulatableBackend.extensionVersion = await planner.getExtensionVersion(instance);
        }
        else if (instance.localPath) {
            emulatableBackend.extensionSpec = await planner.getExtensionSpec(instance);
        }
        return emulatableBackend;
    }
    autoPopulatedParams(instance) {
        var _a;
        const projectId = this.args.projectId;
        return {
            PROJECT_ID: projectId !== null && projectId !== void 0 ? projectId : "",
            EXT_INSTANCE_ID: instance.instanceId,
            DATABASE_INSTANCE: projectId !== null && projectId !== void 0 ? projectId : "",
            DATABASE_URL: `https://${projectId}.firebaseio.com`,
            STORAGE_BUCKET: `${projectId}.appspot.com`,
            ALLOWED_EVENT_TYPES: instance.allowedEventTypes ? instance.allowedEventTypes.join(",") : "",
            EVENTARC_CHANNEL: (_a = instance.eventarcChannel) !== null && _a !== void 0 ? _a : "",
            EVENTARC_CLOUD_EVENT_SOURCE: `projects/${projectId}/instances/${instance.instanceId}`,
        };
    }
    async checkAndWarnAPIs(instances) {
        const apisToWarn = await (0, validation_1.getUnemulatedAPIs)(this.args.projectId, instances);
        if (apisToWarn.length) {
            const table = new Table({
                head: [
                    "API Name",
                    "Instances using this API",
                    `Enabled on ${this.args.projectId}`,
                    `Enable this API`,
                ],
                style: { head: ["yellow"] },
            });
            for (const apiToWarn of apisToWarn) {
                const enablementUri = await (0, shortenUrl_1.shortenUrl)((0, ensureApiEnabled_1.enableApiURI)(this.args.projectId, apiToWarn.apiName));
                table.push([
                    apiToWarn.apiName,
                    apiToWarn.instanceIds,
                    apiToWarn.enabled ? "Yes" : "No",
                    apiToWarn.enabled ? "" : clc.bold(clc.underline(enablementUri)),
                ]);
            }
            if (constants_1.Constants.isDemoProject(this.args.projectId)) {
                this.logger.logLabeled("WARN", "Extensions", "The following Extensions make calls to Google Cloud APIs that do not have Emulators. " +
                    `${clc.bold(this.args.projectId)} is a demo project, so these Extensions may not work as expected.\n` +
                    table.toString());
            }
            else {
                this.logger.logLabeled("WARN", "Extensions", "The following Extensions make calls to Google Cloud APIs that do not have Emulators. " +
                    `These calls will go to production Google Cloud APIs which may have real effects on ${clc.bold(this.args.projectId)}.\n` +
                    table.toString());
            }
        }
    }
    filterUnemulatedTriggers(options, backends) {
        let foundUnemulatedTrigger = false;
        const filteredBackends = backends.filter((backend) => {
            const unemulatedServices = (0, validation_1.checkForUnemulatedTriggerTypes)(backend, options);
            if (unemulatedServices.length) {
                foundUnemulatedTrigger = true;
                const msg = ` ignored becuase it includes ${unemulatedServices.join(", ")} triggered functions, and the ${unemulatedServices.join(", ")} emulator does not exist or is not running.`;
                this.logger.logLabeled("WARN", `extensions[${backend.extensionInstanceId}]`, msg);
            }
            return unemulatedServices.length === 0;
        });
        if (foundUnemulatedTrigger) {
            const msg = "No Cloud Functions for these instances will be emulated, because partially emulating an Extension can lead to unexpected behavior. ";
            this.logger.log("WARN", msg);
        }
        return filteredBackends;
    }
    extensionDetailsUILink(backend) {
        if (!registry_1.EmulatorRegistry.isRunning(types_1.Emulators.UI) || !backend.extensionInstanceId) {
            return "";
        }
        const uiUrl = registry_1.EmulatorRegistry.url(types_1.Emulators.UI);
        uiUrl.pathname = `/${types_1.Emulators.EXTENSIONS}/${backend.extensionInstanceId}`;
        return clc.underline(clc.bold(uiUrl.toString()));
    }
    extensionsInfoTable(options) {
        var _a;
        const filtedBackends = this.filterUnemulatedTriggers(options, this.backends);
        const uiRunning = registry_1.EmulatorRegistry.isRunning(types_1.Emulators.UI);
        const tableHead = ["Extension Instance Name", "Extension Ref"];
        if (uiRunning) {
            tableHead.push("View in Emulator UI");
        }
        const table = new Table({ head: tableHead, style: { head: ["yellow"] } });
        for (const b of filtedBackends) {
            if (b.extensionInstanceId) {
                const tableEntry = [b.extensionInstanceId, ((_a = b.extensionVersion) === null || _a === void 0 ? void 0 : _a.ref) || "Local Extension"];
                if (uiRunning)
                    tableEntry.push(this.extensionDetailsUILink(b));
                table.push(tableEntry);
            }
        }
        return table.toString();
    }
}
exports.ExtensionsEmulator = ExtensionsEmulator;
