import * as fs from "node:fs/promises";
import * as path from "node:path";
import { createNodeLogger, createNodeSys } from "@stencil/core/sys/node";
import {
  CompilerWatcher,
  createCompiler,
  loadConfig,
} from "@stencil/core/compiler";
import { PluginName, ResolvedEmbeddableConfig } from "./defineConfig";
import {
  findFiles,
  getComponentLibraryConfig,
} from "@embeddable.com/sdk-utils";

import * as sorcery from "sorcery";
import { Stats } from "node:fs";
import type { Logger } from "@stencil/core/internal";

const STYLE_IMPORTS_TOKEN = "{{STYLES_IMPORT}}";
const RENDER_IMPORT_TOKEN = "{{RENDER_IMPORT}}";
const PLUGIN_FLAGS_TOKEN = "{{PLUGIN_FLAGS}}";

// stencil doesn't support dynamic component tag name, so we need to replace it manually
const COMPONENT_TAG_TOKEN = "replace-this-with-component-name";

// In dev mode, skip source map chain merging for files above this size.
// Stencil outputs two kinds of files: large framework/runtime bundles (several MB each)
// and small per-component lazy chunks (typically < 50 KB, one per component).
// Loading the large bundles all at once via sorcery exhausts the V8 heap and crashes
// the dev server. The small per-component chunks — from the client's own code and from
// imported libraries alike — stay well under this threshold and still get processed,
// so browser devtools can resolve errors in any component back to the original .tsx source.
const DEV_SOURCEMAP_SIZE_THRESHOLD = 500 * 1024; // 500 KB

let triggeredBuildCount = 0;
/**
 * Stencil watcher doesnt react on file metadata changes,
 * so we have to change the file content to trigger a rebuild by appending a space character.
 * This constant defines how many times the space character can be appended before the file is truncated back to its original size.
 */
export const TRIGGER_BUILD_ITERATION_LIMIT = 5;
let originalFileStats: Stats | null = null;

export function resetForTesting() {
  triggeredBuildCount = 0;
  originalFileStats = null;
}

/**
 * Triggers a rebuild of a Stencil web component by modifying the `component.tsx` file.
 *
 * This function works by appending a space character to the file, which causes Stencil's watcher
 * to detect a change and rebuild the component. After every TRIGGER_BUILD_ITERATION_LIMIT rebuilds, the file is truncated back
 * to its original size to prevent indefinite growth and reset the internal rebuild counter.
 *
 *  Append and truncate are used instead of rewriting the file to ensure minimal I/O overhead and preserve file metadata.
 */
export async function triggerWebComponentRebuild(
  ctx: ResolvedEmbeddableConfig,
): Promise<void> {
  const filePath = path.resolve(ctx.client.componentDir, "component.tsx");

  if (triggeredBuildCount === 0) {
    // store original file stats on the first build
    originalFileStats = await fs.stat(filePath);
  }

  if (triggeredBuildCount === TRIGGER_BUILD_ITERATION_LIMIT && originalFileStats) {
    await fs.truncate(filePath, originalFileStats.size);
    triggeredBuildCount = 0; // reset the counter after resetting the file
  } else {
    await fs.appendFile(filePath, " ");
    triggeredBuildCount++;
  }
}

export default async (
  ctx: ResolvedEmbeddableConfig,
  pluginName: PluginName,
): Promise<void | CompilerWatcher> => {
  await injectCSS(ctx, pluginName);

  await injectBundleRender(ctx, pluginName);

  const watcher = await runStencil(ctx);

  if (watcher) {
    watcher.on("buildFinish", () => {
      // Stencil always changes the working directory to the root of the web component.
      // We need to change it back to the client root directory so that relative paths
      // resolve correctly during source map generation.
      process.chdir(ctx.client.rootDir);
      generateSourceMap(ctx, pluginName);
    });
  } else {
    await generateSourceMap(ctx, pluginName);
  }

  return watcher;
};

/**
 * Generates only the d.ts type declaration files using Stencil, without performing a full build.
 * Used in dev mode to pre-generate types before the watcher starts, avoiding a double-build
 * triggered by the watcher reacting to freshly generated d.ts files.
 *
 * Key differences from the default generate function:
 * - Writes an empty style.css stub (no real CSS injection needed for type generation)
 * - Injects a no-op render stub instead of the real render import
 * - Always creates a fresh sys (never reuses ctx.dev?.sys) to avoid watcher interference
 */
export async function generateDTS(
  ctx: ResolvedEmbeddableConfig,
): Promise<void> {
  await injectEmptyCSS(ctx);

  await injectBundleRenderStub(ctx);

  await runStencil(ctx, { dtsOnly: true });
}

export async function injectCSS(
  ctx: ResolvedEmbeddableConfig,
  pluginName: PluginName,
) {
  const CUSTOMER_BUILD = path.resolve(
    ctx.client.buildDir,
    ctx[pluginName].outputOptions.buildName,
  );
  const allFiles = await fs.readdir(CUSTOMER_BUILD);

  const importFilePath = path
    .relative(
      ctx.client.componentDir,
      path.resolve(ctx.client.buildDir, ctx[pluginName].outputOptions.buildName),
    )
    .replaceAll("\\", "/");

  const imports = allFiles
    .filter((fileName) => fileName.endsWith(".css"))
    .map((fileName) => `@import '${importFilePath}/${fileName}';`);

  const componentLibraries = ctx.client.componentLibraries;
  for (const componentLibrary of componentLibraries) {
    const { libraryName } = getComponentLibraryConfig(componentLibrary);
    const allLibFiles = await fs.readdir(
      path.resolve(ctx.client.rootDir, "node_modules", libraryName, "dist"),
    );
    allLibFiles
      .filter((fileName) => fileName.endsWith(".css"))
      .forEach((fileName) =>
        imports.push(`@import '~${libraryName}/dist/${fileName}';`),
      );
  }

  const cssFilesImportsStr = imports.join("\n");

  const content = await fs.readFile(
    path.resolve(ctx.core.templatesDir, "style.css.template"),
    "utf8",
  );

  await fs.writeFile(
    path.resolve(ctx.client.componentDir, "style.css"),
    content.replace(STYLE_IMPORTS_TOKEN, cssFilesImportsStr),
  );
}

export async function injectBundleRender(
  ctx: ResolvedEmbeddableConfig,
  pluginName: PluginName,
) {
  const importFilePath = path
    .relative(
      ctx.client.componentDir,
      path.resolve(ctx.client.buildDir, ctx[pluginName].outputOptions.buildName),
    )
    .replaceAll("\\", "/");
  const importStr = `import render from '${importFilePath}/${ctx[pluginName].outputOptions.fileName}';`;
  const pluginFlags = ctx[pluginName].pluginFlags ?? {};
  const pluginFlagsStr = `const pluginFlags: Partial<PluginFlags> = ${JSON.stringify(pluginFlags)}`;

  let content = await fs.readFile(
    path.resolve(ctx.core.templatesDir, "component.tsx.template"),
    "utf8",
  );

  if (!!ctx.dev?.watch) {
    content = content.replace(COMPONENT_TAG_TOKEN, "embeddable-component");
  }

  await fs.writeFile(
    path.resolve(ctx.client.componentDir, "component.tsx"),
    content.replace(RENDER_IMPORT_TOKEN, importStr).replace(PLUGIN_FLAGS_TOKEN, pluginFlagsStr),
  );
}

async function injectEmptyCSS(ctx: ResolvedEmbeddableConfig) {
  await fs.writeFile(path.resolve(ctx.client.componentDir, "style.css"), "");
}

async function injectBundleRenderStub(
  ctx: ResolvedEmbeddableConfig,
) {
  const stubStr = `const render = (..._args: any[]) => {};`;

  let content = await fs.readFile(
    path.resolve(ctx.core.templatesDir, "component.tsx.template"),
    "utf8",
  );

  content = content.replace(COMPONENT_TAG_TOKEN, "embeddable-component");
  await fs.writeFile(
    path.resolve(ctx.client.componentDir, "component.tsx"),
    content.replace(RENDER_IMPORT_TOKEN, stubStr).replace(PLUGIN_FLAGS_TOKEN, "const pluginFlags: Partial<PluginFlags> = {}"),
  );
}

async function addComponentTagName(filePath: string, bundleHash: string) {
  // find entry file with a name *.entry.js
  const entryFiles = await findFiles(path.dirname(filePath), /.*\.entry\.js/);

  if (!entryFiles.length) {
    return;
  }

  const entryFileName = entryFiles[0];
  const [entryFileContent, fileContent] = await Promise.all([
    fs.readFile(entryFileName[1], "utf8"),
    fs.readFile(filePath, "utf8"),
  ]);

  const newFileContent = fileContent.replace(
    COMPONENT_TAG_TOKEN,
    `embeddable-component-${bundleHash}`,
  );

  const newEntryFileContent = entryFileContent.replace(
    COMPONENT_TAG_TOKEN.replaceAll("-", "_"),
    `embeddable_component_${bundleHash}`,
  );

  await Promise.all([
    fs.writeFile(filePath, newFileContent),
    fs.writeFile(entryFileName[1], newEntryFileContent),
  ]);
}

async function runStencil(
  ctx: ResolvedEmbeddableConfig,
  options?: { dtsOnly?: boolean },
): Promise<void | CompilerWatcher> {
  const logger = (options?.dtsOnly ? createNodeLogger() : ctx.dev?.logger || createNodeLogger()) as Logger;
  const sys = options?.dtsOnly ? createNodeSys({ process }) : (ctx.dev?.sys || createNodeSys({ process }));
  const devMode = !!ctx.dev?.watch && !options?.dtsOnly;
  if (options?.dtsOnly) {
    logger.setLevel("error")
    logger.createTimeSpan = () => ({
      duration: () => 0,
      finish: () => 0,
    });
  }

  const isWindows = process.platform === "win32";

  const validated = await loadConfig({
    initTsConfig: true,
    logger,
    sys,
    config: {
      devMode,
      maxConcurrentWorkers: isWindows ? 0 : 8, // workers break on windows
      // we will trigger a rebuild by updating the component.tsx file (see triggerBuild function)
      watchIgnoredRegex: [/\.css$/, /\.d\.ts$/, /\.js$/],
      rootDir: ctx.client.webComponentRoot,
      configPath: path.resolve(
        ctx.client.webComponentRoot,
        "stencil.config.ts",
      ),
      tsconfig: path.resolve(ctx.client.webComponentRoot, "tsconfig.json"),
      namespace: "embeddable-wrapper",
      srcDir: ctx.client.componentDir,
      sourceMap: !options?.dtsOnly, // always generate source maps in both dev and prod
      minifyJs: !devMode && !options?.dtsOnly,
      minifyCss: !devMode && !options?.dtsOnly,
      outputTargets: [
        {
          type: "dist",
          buildDir: path.resolve(ctx.client.buildDir, "dist"),
        },
      ],
    },
  });

  const compiler = await createCompiler(validated.config);

  if (devMode) {
    sys.onProcessInterrupt(() => {
      compiler.destroy();
    });
    return await compiler.createWatcher();
  }

  const buildResults = await compiler.build();

  if (buildResults.hasError) {
    console.error("Stencil build error:", buildResults.diagnostics);
    throw new Error("Stencil build error");
  } else {
    await handleStencilBuildOutput(ctx);
  }
  await compiler.destroy();

  process.chdir(ctx.client.rootDir);
}

async function handleStencilBuildOutput(ctx: ResolvedEmbeddableConfig) {
  const entryFilePath = path.resolve(
    ctx.client.stencilBuild,
    "embeddable-wrapper.esm.js",
  );

  let fileName = "embeddable-wrapper.esm.js";

  if (!ctx.dev?.watch && ctx.client.bundleHash) {
    fileName = `embeddable-wrapper.esm-${ctx.client.bundleHash}.js`;
    await addComponentTagName(entryFilePath, ctx.client.bundleHash);
  }

  await fs.rename(
    entryFilePath,
    path.resolve(ctx.client.stencilBuild, fileName),
  );
}

async function generateSourceMap(
  ctx: ResolvedEmbeddableConfig,
  pluginName: PluginName,
) {
  const componentBuildDir = path.resolve(
    ctx.client.buildDir,
    ctx[pluginName].outputOptions.buildName,
  );
  const stencilBuild = path.resolve(ctx.client.stencilBuild);

  const tmpComponentDir = path.resolve(
    stencilBuild,
    ctx[pluginName].outputOptions.buildName,
  );
  await fs.cp(componentBuildDir, tmpComponentDir, { recursive: true });

  const stencilFiles = await fs.readdir(stencilBuild);
  const jsFiles = stencilFiles.filter((file) =>
    file.toLowerCase().endsWith(".js"),
  );

  const isDevMode = !!ctx.dev?.watch;

  // Sequential processing to avoid loading multiple source map chains into memory at once.
  for (const jsFile of jsFiles) {
    try {
      const filePath = path.resolve(stencilBuild, jsFile);

      // In dev mode, skip large files (framework/runtime bundles).
      // Per-component chunks from both client and library code are small enough
      // to process, keeping source maps working in browser devtools.
      if (isDevMode) {
        const { size } = await fs.stat(filePath);
        if (size > DEV_SOURCEMAP_SIZE_THRESHOLD) {
          continue;
        }
      }

      const chain = await sorcery.load(filePath);
      // overwrite the existing file
      await chain.write();
    } catch (e) {
      // do nothing if a map file can not be generated
    }
  }

  await fs.rm(tmpComponentDir, { recursive: true });
}
