import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { AppMetaSchema, type AppMetaType } from "@calcom/types/AppMetaSchema";
import chokidar from "chokidar";
// eslint-disable-next-line no-restricted-imports
import { debounce } from "lodash";
import { APP_STORE_PATH } from "./constants";
import { getAppName } from "./utils/getAppName";

const isInWatchMode = process.argv[2] === "--watch";

const repoRoot = path.resolve(__dirname, "../../..");

const formatFileWithBiome = (filePath: string) => {
  // Normalize to forward slashes for cross-platform Biome compatibility
  const normalizedPath = filePath.replace(/\\/g, "/");
  const { status } = spawnSync(
    "yarn",
    ["biome", "format", "--write", "--no-errors-on-unmatched", normalizedPath],
    {
      stdio: "inherit",
      cwd: repoRoot,
      shell: true,
    }
  );

  if (status !== 0) {
    throw new Error(`Biome formatting failed for ${filePath}`);
  }
};

const formatOutput = (source: string) => source;

const getVariableName = (appName: string) => appName.replace(/[-./]/g, "_");

// INFO: Handle stripe separately as it's an old app with different dirName than slug/appId
const getAppId = (app: { name: string }) => (app.name === "stripepayment" ? "stripe" : app.name);

type App = Partial<AppMetaType> & {
  name: string;
  path: string;
};
function generateFiles() {
  const browserOutput = [`import dynamic from "next/dynamic"`];
  const metadataOutput = [];
  const bookerMetadataOutput = [];
  const schemasOutput = [];
  const appKeysSchemasOutput = [];
  const serverOutput = [];
  const crmOutput = [];
  const appDirs: { name: string; path: string }[] = [];

  fs.readdirSync(`${APP_STORE_PATH}`).forEach((dir) => {
    if (dir === "ee" || dir === "templates") {
      fs.readdirSync(path.join(APP_STORE_PATH, dir)).forEach((subDir) => {
        if (fs.statSync(path.join(APP_STORE_PATH, dir, subDir)).isDirectory()) {
          if (getAppName(subDir)) {
            appDirs.push({
              name: subDir,
              path: path.join(dir, subDir),
            });
          }
        }
      });
    } else {
      if (fs.statSync(path.join(APP_STORE_PATH, dir)).isDirectory()) {
        if (!getAppName(dir)) {
          return;
        }
        appDirs.push({
          name: dir,
          path: dir,
        });
      }
    }
  });

  function forEachAppDir(callback: (arg: App) => void, filter: (arg: App) => boolean = () => true) {
    for (let i = 0; i < appDirs.length; i++) {
      const configPath = path.join(APP_STORE_PATH, appDirs[i].path, "config.json");
      const metadataPath = path.join(APP_STORE_PATH, appDirs[i].path, "_metadata.ts");
      let app: Partial<AppMetaType>;

      if (fs.existsSync(configPath)) {
        try {
          const rawConfig = fs.readFileSync(configPath, "utf8");
          const parsedConfig = JSON.parse(rawConfig);
          app = AppMetaSchema.parse(parsedConfig);
        } catch (error) {
          const prefix = `Config error in ${path.join(APP_STORE_PATH, appDirs[i].path, "config.json")}`;
          throw new Error(`${prefix}: ${error instanceof Error ? error.message : String(error)}`);
        }
      } else if (fs.existsSync(metadataPath)) {
        // eslint-disable-next-line @typescript-eslint/no-var-requires
        app = require(metadataPath).metadata;
      } else {
        app = {};
      }

      const finalApp = {
        ...app,
        name: appDirs[i].name,
        path: appDirs[i].path,
      };

      if (filter(finalApp)) {
        callback(finalApp);
      }
    }
  }

  /**
   * Windows has paths with backslashes, so we need to replace them with forward slashes
   * .ts and .tsx files are imported without extensions
   * If a file has index.ts or index.tsx, it can be imported after removing the index.ts* part
   */
  function getModulePath(path: string, moduleName: string) {
    return `./${path.replace(/\\/g, "/")}/${moduleName
      .replace(/\/index\.ts|\/index\.tsx/, "")
      .replace(/\.tsx$|\.ts$/, "")}`;
  }

  type ImportConfig =
    | {
        fileToBeImported: string;
        importName?: string;
      }
    | [
        {
          fileToBeImported: string;
          importName?: string;
        },
        {
          fileToBeImported: string;
          importName: string;
        },
      ];

  /**
   * If importConfig is an array, only 2 items are allowed. First one is the main one and second one is the fallback
   */
  function getExportedObject(
    objectName: string,
    {
      lazyImport = false,
      importConfig,
      entryObjectKeyGetter = (app) => app.name,
    }: {
      lazyImport?: boolean;
      importConfig: ImportConfig;
      entryObjectKeyGetter?: (arg: App, importName?: string) => string;
    },
    filter?: (arg: App) => boolean
  ) {
    const output: string[] = [];

    const getLocalImportName = (
      app: { name: string },
      chosenConfig: ReturnType<typeof getChosenImportConfig>
    ) => `${getVariableName(app.name)}_${getVariableName(chosenConfig.fileToBeImported)}`;

    const fileToBeImportedExists = (
      app: { path: string },
      chosenConfig: ReturnType<typeof getChosenImportConfig>
    ) => fs.existsSync(path.join(APP_STORE_PATH, app.path, chosenConfig.fileToBeImported));

    addImportStatements();
    createExportObject();

    return output;

    function addImportStatements() {
      forEachAppDir((app) => {
        const chosenConfig = getChosenImportConfig(importConfig, app);
        if (fileToBeImportedExists(app, chosenConfig) && chosenConfig.importName) {
          const importName = chosenConfig.importName;
          if (!lazyImport) {
            if (importName !== "default") {
              // Import with local alias that will be used by createExportObject
              output.push(
                `import { ${importName} as ${getLocalImportName(app, chosenConfig)} } from "${getModulePath(
                  app.path,
                  chosenConfig.fileToBeImported
                )}"`
              );
            } else {
              // Default Import
              output.push(
                `import ${getLocalImportName(app, chosenConfig)} from "${getModulePath(
                  app.path,
                  chosenConfig.fileToBeImported
                )}"`
              );
            }
          }
        }
      }, filter);
    }

    function createExportObject() {
      output.push(`export const ${objectName} = {`);

      forEachAppDir((app) => {
        const chosenConfig = getChosenImportConfig(importConfig, app);

        if (fileToBeImportedExists(app, chosenConfig)) {
          if (!lazyImport) {
            const key = entryObjectKeyGetter(app);
            output.push(`"${key}": ${getLocalImportName(app, chosenConfig)},`);
          } else {
            const key = entryObjectKeyGetter(app);
            if (chosenConfig.fileToBeImported.endsWith(".tsx")) {
              output.push(
                `"${key}": dynamic(() => import("${getModulePath(
                  app.path,
                  chosenConfig.fileToBeImported
                )}")),`
              );
            } else {
              output.push(`"${key}": import("${getModulePath(app.path, chosenConfig.fileToBeImported)}"),`);
            }
          }
        }
      }, filter);

      output.push(`};`);
    }

    function getChosenImportConfig(importConfig: ImportConfig, app: { path: string }) {
      let chosenConfig: ImportConfig;

      if (!Array.isArray(importConfig)) {
        chosenConfig = importConfig;
      } else {
        if (fs.existsSync(path.join(APP_STORE_PATH, app.path, importConfig[0].fileToBeImported))) {
          chosenConfig = importConfig[0];
        } else {
          chosenConfig = importConfig[1];
        }
      }
      return chosenConfig;
    }
  }

  serverOutput.push(
    ...getExportedObject("apiHandlers", {
      importConfig: {
        fileToBeImported: "api/index.ts",
      },
      lazyImport: true,
    })
  );

  metadataOutput.push(
    ...getExportedObject("appStoreMetadata", {
      // Try looking for config.json and if it's not found use _metadata.ts to generate appStoreMetadata
      importConfig: [
        {
          fileToBeImported: "config.json",
          importName: "default",
        },
        {
          fileToBeImported: "_metadata.ts",
          importName: "metadata",
        },
      ],
    })
  );

  bookerMetadataOutput.push(
    ...getExportedObject(
      "appStoreMetadata",
      {
        // Try looking for config.json and if it's not found use _metadata.ts to generate appStoreMetadata
        importConfig: [
          {
            fileToBeImported: "config.json",
            importName: "default",
          },
          {
            fileToBeImported: "_metadata.ts",
            importName: "metadata",
          },
        ],
      },
      isBookerApp
    )
  );
  schemasOutput.push(
    ...getExportedObject("appDataSchemas", {
      // Import path must have / even for windows and not \
      importConfig: {
        fileToBeImported: "zod.ts",
        importName: "appDataSchema",
      },
      // HACK: Key must be appId as this is used by eventType metadata and lookup is by appId
      // This can be removed once we rename the ids of apps like stripe to that of their app folder name
      entryObjectKeyGetter: (app) => getAppId(app),
    })
  );

  appKeysSchemasOutput.push(
    ...getExportedObject("appKeysSchemas", {
      importConfig: {
        fileToBeImported: "zod.ts",
        importName: "appKeysSchema",
      },
      // HACK: Key must be appId as this is used by eventType metadata and lookup is by appId
      // This can be removed once we rename the ids of apps like stripe to that of their app folder name
      entryObjectKeyGetter: (app) => getAppId(app),
    })
  );

  browserOutput.push(
    ...getExportedObject("InstallAppButtonMap", {
      importConfig: {
        fileToBeImported: "components/InstallAppButton.tsx",
      },
      lazyImport: true,
    })
  );

  // TODO: Make a component map creator that accepts ComponentName and does the rest.
  // TODO: dailyvideo has a slug of daily-video, so that mapping needs to be taken care of. But it is an old app, so it doesn't need AppSettings
  browserOutput.push(
    ...getExportedObject("AppSettingsComponentsMap", {
      importConfig: {
        fileToBeImported: "components/AppSettingsInterface.tsx",
      },
      lazyImport: true,
    })
  );

  browserOutput.push(
    ...getExportedObject("EventTypeAddonMap", {
      importConfig: {
        fileToBeImported: "components/EventTypeAppCardInterface.tsx",
      },
      lazyImport: true,
    })
  );
  browserOutput.push(
    ...getExportedObject("EventTypeSettingsMap", {
      importConfig: {
        fileToBeImported: "components/EventTypeAppSettingsInterface.tsx",
      },
      lazyImport: true,
    })
  );

  crmOutput.push(
    ...getExportedObject(
      "CrmServiceMap",
      {
        importConfig: {
          fileToBeImported: "lib/CrmService.ts",
          importName: "default",
        },
        lazyImport: true,
      },
      isCrmApp
    )
  );

  const calendarOutput = [];
  const calendarServices = getExportedObject(
    "CalendarServiceMap",
    {
      importConfig: {
        fileToBeImported: "lib/CalendarService.ts",
        importName: "default",
      },
      lazyImport: true,
    },
    isCalendarApp
  );

  // Find the export line and wrap it with E2E conditional
  const exportLineIndex = calendarServices.findIndex((line) =>
    line.startsWith("export const CalendarServiceMap")
  );
  if (exportLineIndex !== -1) {
    const exportLine = calendarServices[exportLineIndex];
    const objectContent = calendarServices.slice(exportLineIndex + 1, -1); // Remove export line and closing brace

    calendarOutput.push(
      exportLine.replace(
        "export const CalendarServiceMap = {",
        "export const CalendarServiceMap = process.env.NEXT_PUBLIC_IS_E2E === '1' ? {} : {"
      ),
      ...objectContent,
      "};"
    );
  } else {
    calendarOutput.push(...calendarServices);
  }

  const analyticsOutput = [];
  const analyticsServices = getExportedObject(
    "AnalyticsServiceMap",
    {
      importConfig: {
        fileToBeImported: "lib/AnalyticsService.ts",
        importName: "default",
      },
      lazyImport: true,
    },
    (app: App) => {
      const hasAnalyticsService = fs.existsSync(
        path.join(APP_STORE_PATH, app.path, "lib/AnalyticsService.ts")
      );
      return hasAnalyticsService;
    }
  );

  const analyticsExportLineIndex = analyticsServices.findIndex((line) =>
    line.startsWith("export const AnalyticsServiceMap")
  );
  if (analyticsExportLineIndex !== -1) {
    const exportLine = analyticsServices[analyticsExportLineIndex];
    const objectContent = analyticsServices.slice(analyticsExportLineIndex + 1, -1);

    analyticsOutput.push(
      exportLine.replace(
        "export const AnalyticsServiceMap = {",
        "export const AnalyticsServiceMap = process.env.NEXT_PUBLIC_IS_E2E === '1' ? {} : {"
      ),
      ...objectContent,
      "};"
    );
  } else {
    analyticsOutput.push(...analyticsServices);
  }

  const paymentOutput = [];
  const paymentServices = getExportedObject(
    "PaymentServiceMap",
    {
      importConfig: {
        fileToBeImported: "lib/PaymentService.ts",
        importName: "PaymentService",
      },
      lazyImport: true,
    },
    (app: App) => {
      const hasPaymentService = fs.existsSync(path.join(APP_STORE_PATH, app.path, "lib/PaymentService.ts"));
      return hasPaymentService;
    }
  );

  paymentOutput.push(...paymentServices);

  const videoOutput = [];
  const videoAdapters = getExportedObject(
    "VideoApiAdapterMap",
    {
      importConfig: {
        fileToBeImported: "lib/VideoApiAdapter.ts",
        importName: "default",
      },
      lazyImport: true,
    },
    (app: App) => {
      return fs.existsSync(path.join(APP_STORE_PATH, app.path, "lib/VideoApiAdapter.ts"));
    }
  );

  const videoExportLineIndex = videoAdapters.findIndex((line) =>
    line.startsWith("export const VideoApiAdapterMap")
  );
  if (videoExportLineIndex !== -1) {
    const exportLine = videoAdapters[videoExportLineIndex];
    const objectContent = videoAdapters.slice(videoExportLineIndex + 1, -1);

    videoOutput.push(
      exportLine.replace(
        "export const VideoApiAdapterMap = {",
        "export const VideoApiAdapterMap = process.env.NEXT_PUBLIC_IS_E2E === '1' ? {} : {"
      ),
      ...objectContent,
      "};"
    );
  } else {
    videoOutput.push(...videoAdapters);
  }

  // Generate redirect apps list (apps with externalLink in their config, excluding templates)
  const redirectAppsOutput: string[] = [];
  const redirectAppSlugs: string[] = [];
  forEachAppDir((app) => {
    // Exclude templates - they are not actual apps
    if (app.externalLink && !app.path.replace(/\\/g, "/").startsWith("templates/")) {
      redirectAppSlugs.push(app.name);
    }
  });
  redirectAppsOutput.push(`export const REDIRECT_APPS = ${JSON.stringify(redirectAppSlugs)} as const;`);
  redirectAppsOutput.push(`export type RedirectApp = typeof REDIRECT_APPS[number];`);

  const banner = `/**
    This file is autogenerated using the command \`yarn app-store:build --watch\`.
    Don't modify this file manually.
**/
`;
  const filesToGenerate: [string, string[]][] = [
    ["analytics.services.generated.ts", analyticsOutput],
    ["apps.metadata.generated.ts", metadataOutput],
    ["apps.server.generated.ts", serverOutput],
    ["apps.browser.generated.tsx", browserOutput],
    ["apps.schemas.generated.ts", schemasOutput],
    ["apps.keys-schemas.generated.ts", appKeysSchemasOutput],
    ["bookerApps.metadata.generated.ts", bookerMetadataOutput],
    ["crm.apps.generated.ts", crmOutput],
    ["calendar.services.generated.ts", calendarOutput],
    ["payment.services.generated.ts", paymentOutput],
    ["video.adapters.generated.ts", videoOutput],
    ["redirect-apps.generated.ts", redirectAppsOutput],
  ];
  filesToGenerate.forEach(([fileName, output]) => {
    const filePath = path.join(APP_STORE_PATH, fileName);
    fs.writeFileSync(filePath, formatOutput(`${banner}${output.join("\n")}`));
    formatFileWithBiome(filePath);
  });
  console.log(`Generated ${filesToGenerate.map(([fileName]) => fileName).join(", ")}`);
}

const debouncedGenerateFiles = debounce(generateFiles);

if (isInWatchMode) {
  chokidar
    .watch(APP_STORE_PATH)
    .on("addDir", (dirPath) => {
      const appName = getAppName(dirPath);
      if (appName) {
        console.log(`Added ${appName}`);
        debouncedGenerateFiles();
      }
    })
    .on("change", (filePath) => {
      if (filePath.endsWith("config.json")) {
        console.log("Config file changed");
        debouncedGenerateFiles();
      }
    })
    .on("unlinkDir", (dirPath) => {
      const appName = getAppName(dirPath);
      if (appName) {
        console.log(`Removed ${appName}`);
        debouncedGenerateFiles();
      }
    });
} else {
  generateFiles();
}

function isBookerApp(app: App) {
  // Right now there are only two types of Apps that booker needs.
  // Note that currently payment apps' meta don't need to be accessed on booker. We just access from DB eventType.metadata
  // 1. It is a location app(e.g. any Conferencing App)
  // 2. It is a tag manager app(e.g. Google Analytics, GTM, Fathom)
  return !!(app.appData?.location || app.appData?.tag);
}

function isCrmApp(app: App) {
  return !!app.categories?.includes("crm");
}

function isCalendarApp(app: App) {
  return !!app.categories?.includes("calendar");
}
