import generate, {
  resetForTesting, TRIGGER_BUILD_ITERATION_LIMIT,
  triggerWebComponentRebuild, generateDTS,
  injectBundleRender, injectCSS,
} from "./generate";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import * as sorcery from "sorcery";
import { checkNodeVersion } from "./utils";
import { loadConfig, createCompiler } from "@stencil/core/compiler";
import { findFiles, getContentHash } from "@embeddable.com/sdk-utils";
import { ResolvedEmbeddableConfig } from "./defineConfig";
const config = {
  client: {
    rootDir: "rootDir",
    srcDir: "srcDir",
    buildDir: "buildDir",
    tmpDir: "tmpDir",
    stencilBuild: "stencilBuild",
    componentDir: "componentDir",
    webComponentRoot: "webComponentRoot",
    bundleHash: "hash",
    componentLibraries: [],
  },
  core: {
    rootDir: "rootDir",
    templatesDir: "templatesDir",
    configsDir: "configsDir",
  },
  "sdk-react": {
    rootDir: "rootDir",
    templatesDir: "templatesDir",
    configsDir: "configsDir",
    outputOptions: {
      buildName: "buildName",
    },
  },
};

vi.mock("@embeddable.com/sdk-utils", () => ({
  getContentHash: vi.fn(),
  findFiles: vi.fn(),
}));

vi.mock("./utils", () => ({
  checkNodeVersion: vi.fn(),
}));

vi.mock("./provideConfig", () => ({
  provideConfig: vi.fn().mockResolvedValue(config),
}));

vi.mock("node:fs/promises", () => ({
  writeFile: vi.fn(),
  readdir: vi.fn(),
  readFile: vi.fn(),
  rename: vi.fn(),
  cp: vi.fn(),
  rm: vi.fn(),
  copyFile: vi.fn(),
  stat: vi.fn(),
  truncate: vi.fn(),
  appendFile: vi.fn(),
}));

vi.mock("node:path", async () => {
  const actual = await vi.importActual("node:path");
  return {
    ...actual,
    resolve: vi.fn(),
    join: vi.fn(),
    relative: vi.fn(),
  };
});

vi.mock("@stencil/core/compiler", () => ({
  createCompiler: vi.fn(),
  loadConfig: vi.fn(),
}));

vi.mock("sorcery", () => ({
  load: vi.fn(),
}));

describe("generate", () => {
  const watcherMock = vi.fn().mockResolvedValue({
    hasError: false,
    on: vi.fn(),
  });
  beforeEach(() => {
    vi.mocked(checkNodeVersion).mockResolvedValue(true);
    vi.mocked(fs.readdir).mockResolvedValue([
      "embeddable-wrapper.esm.js",
      "embeddable-wrapper.esm.js.map",
      "styles.css",
    ] as any);
    vi.mocked(path.resolve).mockImplementation((...args) => args.join("/"));
    vi.mocked(path.relative).mockReturnValue("../buildDir/buildName");
    vi.mocked(fs.readFile).mockResolvedValue("");
    vi.mocked(loadConfig).mockResolvedValue({
      config: {},
    } as any);
    vi.mocked(createCompiler).mockResolvedValue({
      build: vi.fn().mockResolvedValue({
        hasError: false,
      }),
      destroy: vi.fn(),
      createWatcher: watcherMock,
    } as any);

    vi.mocked(getContentHash).mockReturnValue("hash");
    vi.mocked(findFiles).mockResolvedValue([["", ""]]);

    Object.defineProperties(process, {
      chdir: {
        value: vi.fn(),
      },
    });
  });

  it("should generate bundle", async () => {
    await generate(config as unknown as ResolvedEmbeddableConfig, "sdk-react");

    // should inject css
    expect(fs.writeFile).toHaveBeenCalledWith("componentDir/style.css", "");

    // should inject bundle renderer
    expect(fs.writeFile).toHaveBeenCalledWith("componentDir/component.tsx", "");

    expect(loadConfig).toHaveBeenCalled();

    expect(createCompiler).toHaveBeenCalledWith({});

    // check if the file is renamed
    expect(fs.rename).toHaveBeenCalledWith(
      "stencilBuild/embeddable-wrapper.esm.js",
      "stencilBuild/embeddable-wrapper.esm-hash.js",
    );
  });

  it("should generate bundle in dev mode", async () => {
    const ctx = {
      ...config,
      dev: {
        watch: true,
        logger: vi.fn(),
        sys: vi.mocked({
          onProcessInterrupt: vi.fn(),
        }),
      },
    };

    vi.mocked(fs.readFile).mockResolvedValue(
      "replace-this-with-component-name",
    );
    await generate(ctx as unknown as ResolvedEmbeddableConfig, "sdk-react");

    expect(createCompiler).toHaveBeenCalled();
    expect(watcherMock).toHaveBeenCalled();

    expect(fs.writeFile).toHaveBeenCalledWith(
      "componentDir/component.tsx",
      expect.stringContaining("embeddable-component"),
    );

    expect(loadConfig).toHaveBeenCalledWith({
      config: {
        configPath: "webComponentRoot/stencil.config.ts",
        devMode: true,
        watchIgnoredRegex: [/\.css$/, /\.d\.ts$/, /\.js$/],
        maxConcurrentWorkers: process.platform === "win32" ? 0 : 8,
        minifyCss: false,
        minifyJs: false,
        namespace: "embeddable-wrapper",
        outputTargets: [
          {
            type: "dist",
            buildDir: "buildDir/dist",
          },
        ],
        rootDir: "webComponentRoot",
        sourceMap: true,
        srcDir: "componentDir",
        tsconfig: "webComponentRoot/tsconfig.json",
      },
      initTsConfig: true,
      logger: expect.any(Function),
      sys: {
        onProcessInterrupt: expect.any(Function),
      },
    });
  });
});

describe("triggerWebComponentRebuild", () => {
  beforeEach(() => {
    vi.clearAllMocks();
    resetForTesting();
  });

  it("should store original file stats on first call and append file", async () => {
    const mockStats = { size: 123 };
    vi.mocked(fs.stat).mockResolvedValue(mockStats as any);

    await triggerWebComponentRebuild(
      config as unknown as ResolvedEmbeddableConfig,
    );

    const filePath = path.resolve(config.client.componentDir, "component.tsx");
    expect(fs.stat).toHaveBeenCalledWith(filePath);
    expect(fs.appendFile).toHaveBeenCalledWith(filePath, " ");
  });

  it("should append file and not call stat after first build", async () => {
    const mockStats = { size: 123 };
    vi.mocked(fs.stat).mockResolvedValue(mockStats as any);

    for (let i = 0; i < 3; i++) {
      await triggerWebComponentRebuild(
        config as unknown as ResolvedEmbeddableConfig,
      );
    }

    expect(fs.stat).toHaveBeenCalledTimes(1); // only once
    expect(fs.appendFile).toHaveBeenCalledTimes(3);
    expect(fs.truncate).not.toHaveBeenCalled();
  });

  it("should reset file using truncate on the 6th call and reset count", async () => {
    const mockStats = { size: 321 };
    vi.mocked(fs.stat).mockResolvedValue(mockStats as any);
    vi.mocked(path.resolve).mockReturnValue("componentDir/component.tsx");

    for (let i = 0; i < TRIGGER_BUILD_ITERATION_LIMIT; i++) {
      await triggerWebComponentRebuild(
        config as unknown as ResolvedEmbeddableConfig,
      );
    }

    expect(fs.truncate).not.toHaveBeenCalled();

    vi.mocked(fs.appendFile).mockClear();
    vi.mocked(fs.truncate).mockClear();

    // now truncate should be called
    await triggerWebComponentRebuild(
      config as unknown as ResolvedEmbeddableConfig,
    );
    const filePath = path.resolve(config.client.componentDir, "component.tsx");

    expect(fs.truncate).toHaveBeenCalledWith(filePath, mockStats.size);
    expect(fs.appendFile).not.toHaveBeenCalledWith(filePath, " ");
  });
});

describe("generateDTS", () => {
  beforeEach(() => {
    vi.mocked(fs.readdir).mockResolvedValue([
      "embeddable-wrapper.esm.js",
    ] as any);
    vi.mocked(path.resolve).mockImplementation((...args) => args.join("/"));
    // Template contains all tokens so we can verify replacement
    vi.mocked(fs.readFile).mockResolvedValue(
      "replace-this-with-component-name {{RENDER_IMPORT}} {{PLUGIN_FLAGS}}",
    );
    vi.mocked(loadConfig).mockResolvedValue({ config: {} } as any);
    vi.mocked(createCompiler).mockResolvedValue({
      build: vi.fn().mockResolvedValue({ hasError: false }),
      destroy: vi.fn(),
      createWatcher: vi.fn(),
    } as any);
    vi.mocked(findFiles).mockResolvedValue([["", ""]]);
    Object.defineProperties(process, { chdir: { value: vi.fn() } });
  });

  it("should write an empty style.css", async () => {
    await generateDTS(config as unknown as ResolvedEmbeddableConfig);

    expect(fs.writeFile).toHaveBeenCalledWith(
      "componentDir/style.css",
      "",
    );
  });

  it("should write component.tsx with stub render and embeddable-component tag", async () => {
    await generateDTS(config as unknown as ResolvedEmbeddableConfig);

    expect(fs.writeFile).toHaveBeenCalledWith(
      "componentDir/component.tsx",
      expect.stringContaining("embeddable-component"),
    );
    expect(fs.writeFile).toHaveBeenCalledWith(
      "componentDir/component.tsx",
      expect.stringContaining("const render = (..._args: any[]) => {};"),
    );
  });

  it("should replace {{PLUGIN_FLAGS}} token with empty pluginFlags", async () => {
    await generateDTS(config as unknown as ResolvedEmbeddableConfig);

    expect(fs.writeFile).toHaveBeenCalledWith(
      "componentDir/component.tsx",
      expect.stringContaining("const pluginFlags: Partial<PluginFlags> = {}"),
    );
    expect(fs.writeFile).toHaveBeenCalledWith(
      "componentDir/component.tsx",
      expect.not.stringContaining("{{PLUGIN_FLAGS}}"),
    );
  });

  it("should call loadConfig with devMode=false and sourceMap=false", async () => {
    await generateDTS(config as unknown as ResolvedEmbeddableConfig);

    expect(loadConfig).toHaveBeenCalledWith(
      expect.objectContaining({
        config: expect.objectContaining({
          devMode: false,
          sourceMap: false,
          minifyJs: false,
          minifyCss: false,
        }),
      }),
    );
  });

  it("should not create a watcher (not watch mode)", async () => {
    const createWatcherMock = vi.fn();
    vi.mocked(createCompiler).mockResolvedValue({
      build: vi.fn().mockResolvedValue({ hasError: false }),
      destroy: vi.fn(),
      createWatcher: createWatcherMock,
    } as any);

    await generateDTS(config as unknown as ResolvedEmbeddableConfig);

    expect(createWatcherMock).not.toHaveBeenCalled();
  });
});

describe("injectBundleRender cross-platform paths", () => {
  const ctxWithFileName = {
    ...config,
    "sdk-react": {
      ...config["sdk-react"],
      outputOptions: {
        buildName: "buildName",
        fileName: "render.js",
      },
    },
  };

  beforeEach(() => {
    vi.mocked(path.resolve).mockImplementation((...args) => args.join("/"));
    vi.mocked(fs.readFile).mockResolvedValue("{{RENDER_IMPORT}}\n{{PLUGIN_FLAGS}}");
  });

  it("should use forward slashes in import when path.relative returns unix path", async () => {
    vi.mocked(path.relative).mockReturnValue("../../buildDir/buildName");

    await injectBundleRender(
      ctxWithFileName as unknown as ResolvedEmbeddableConfig,
      "sdk-react",
    );

    expect(fs.writeFile).toHaveBeenCalledWith(
      expect.any(String),
      expect.stringContaining("import render from '../../buildDir/buildName/render.js'"),
    );
  });

  it("should replace backslashes with forward slashes when path.relative returns windows path", async () => {
    vi.mocked(path.relative).mockReturnValue("..\\..\\buildDir\\buildName");

    await injectBundleRender(
      ctxWithFileName as unknown as ResolvedEmbeddableConfig,
      "sdk-react",
    );

    expect(fs.writeFile).toHaveBeenCalledWith(
      expect.any(String),
      expect.stringContaining("import render from '../../buildDir/buildName/render.js'"),
    );
  });

  it("should inject pluginFlags from config into component.tsx", async () => {
    vi.mocked(path.relative).mockReturnValue("../../buildDir/buildName");
    const ctxWithPluginFlags = {
      ...ctxWithFileName,
      "sdk-react": {
        ...ctxWithFileName["sdk-react"],
        pluginFlags: { supportsOnComponentReadyHook: true },
      },
    };

    await injectBundleRender(
      ctxWithPluginFlags as unknown as ResolvedEmbeddableConfig,
      "sdk-react",
    );

    expect(fs.writeFile).toHaveBeenCalledWith(
      expect.any(String),
      expect.stringContaining('const pluginFlags: Partial<PluginFlags> = {"supportsOnComponentReadyHook":true}'),
    );
  });

  it("should inject empty pluginFlags when not present in config", async () => {
    vi.mocked(path.relative).mockReturnValue("../../buildDir/buildName");

    await injectBundleRender(
      ctxWithFileName as unknown as ResolvedEmbeddableConfig,
      "sdk-react",
    );

    expect(fs.writeFile).toHaveBeenCalledWith(
      expect.any(String),
      expect.stringContaining("const pluginFlags: Partial<PluginFlags> = {}"),
    );
  });

  it("should not leave {{PLUGIN_FLAGS}} token in output", async () => {
    vi.mocked(path.relative).mockReturnValue("../../buildDir/buildName");

    await injectBundleRender(
      ctxWithFileName as unknown as ResolvedEmbeddableConfig,
      "sdk-react",
    );

    expect(fs.writeFile).toHaveBeenCalledWith(
      expect.any(String),
      expect.not.stringContaining("{{PLUGIN_FLAGS}}"),
    );
  });
});

describe("injectCSS cross-platform paths", () => {
  beforeEach(() => {
    vi.mocked(path.resolve).mockImplementation((...args) => args.join("/"));
    vi.mocked(fs.readFile).mockResolvedValue("{{STYLES_IMPORT}}");
    vi.mocked(fs.readdir).mockResolvedValue(["main.css"] as any);
  });

  it("should use forward slashes in @import when path.relative returns unix path", async () => {
    vi.mocked(path.relative).mockReturnValue("../../buildDir/buildName");

    await injectCSS(
      config as unknown as ResolvedEmbeddableConfig,
      "sdk-react",
    );

    expect(fs.writeFile).toHaveBeenCalledWith(
      expect.any(String),
      expect.stringContaining("@import '../../buildDir/buildName/main.css'"),
    );
  });

  it("should replace backslashes with forward slashes when path.relative returns windows path", async () => {
    vi.mocked(path.relative).mockReturnValue("..\\..\\buildDir\\buildName");

    await injectCSS(
      config as unknown as ResolvedEmbeddableConfig,
      "sdk-react",
    );

    expect(fs.writeFile).toHaveBeenCalledWith(
      expect.any(String),
      expect.stringContaining("@import '../../buildDir/buildName/main.css'"),
    );
  });
});

describe("generate stencil build error", () => {
  beforeEach(() => {
    vi.mocked(path.resolve).mockImplementation((...args) => args.join("/"));
    vi.mocked(path.relative).mockReturnValue("../buildDir/buildName");
    vi.mocked(fs.readFile).mockResolvedValue("");
    vi.mocked(fs.readdir).mockResolvedValue([] as any);
    vi.mocked(loadConfig).mockResolvedValue({ config: {} } as any);
    vi.mocked(findFiles).mockResolvedValue([["", ""]]);
  });

  it("should throw when Stencil build has errors", async () => {
    vi.mocked(createCompiler).mockResolvedValue({
      build: vi.fn().mockResolvedValue({
        hasError: true,
        diagnostics: [{ messageText: "type error" }],
      }),
      destroy: vi.fn(),
      createWatcher: vi.fn(),
    } as any);

    const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});

    await expect(
      generate(config as unknown as ResolvedEmbeddableConfig, "sdk-react"),
    ).rejects.toThrow("Stencil build error");

    expect(consoleSpy).toHaveBeenCalledWith(
      "Stencil build error:",
      expect.anything(),
    );

    consoleSpy.mockRestore();
  });
});

describe("generate - dev mode source map generation", () => {
  let buildFinishCallback: (() => void) | undefined;

  const devCtx = {
    ...config,
    dev: {
      watch: true,
      logger: vi.fn(),
      sys: {
        onProcessInterrupt: vi.fn(),
      },
    },
  };

  // Flush all pending microtasks so async work scheduled via promise chains completes
  // before assertions run.
  const flushPromises = () => new Promise<void>((resolve) => setTimeout(resolve, 0));

  beforeEach(() => {
    buildFinishCallback = undefined;

    vi.mocked(path.resolve).mockImplementation((...args) => args.join("/"));
    vi.mocked(path.relative).mockReturnValue("../buildDir/buildName");
    vi.mocked(fs.readFile).mockResolvedValue("{{RENDER_IMPORT}} {{PLUGIN_FLAGS}}");
    vi.mocked(fs.cp).mockResolvedValue(undefined);
    vi.mocked(fs.rm).mockResolvedValue(undefined);
    vi.mocked(loadConfig).mockResolvedValue({ config: {} } as any);
    vi.mocked(createCompiler).mockResolvedValue({
      build: vi.fn().mockResolvedValue({ hasError: false }),
      destroy: vi.fn(),
      createWatcher: vi.fn().mockResolvedValue({
        on: vi.fn().mockImplementation((event: string, cb: () => void) => {
          if (event === "buildFinish") {
            buildFinishCallback = cb;
          }
        }),
      }),
    } as any);
    vi.mocked(findFiles).mockResolvedValue([["", ""]]);
    vi.mocked(sorcery.load).mockResolvedValue({ write: vi.fn() } as any);
    Object.defineProperties(process, { chdir: { value: vi.fn() } });
  });

  it("calls process.chdir with client.rootDir before source map generation on buildFinish", async () => {
    vi.mocked(fs.readdir).mockResolvedValue([] as any);

    await generate(devCtx as unknown as ResolvedEmbeddableConfig, "sdk-react");
    expect(buildFinishCallback).toBeDefined();

    buildFinishCallback!();
    await flushPromises();

    expect(process.chdir).toHaveBeenCalledWith(devCtx.client.rootDir);
  });

  it("skips files larger than 500 KB in dev mode source map generation", async () => {
    vi.mocked(fs.readdir).mockResolvedValue(["large-bundle.js"] as any);
    vi.mocked(fs.stat).mockResolvedValue({ size: 600 * 1024 } as any); // 600 KB > threshold

    await generate(devCtx as unknown as ResolvedEmbeddableConfig, "sdk-react");
    buildFinishCallback!();
    await flushPromises();

    expect(sorcery.load).not.toHaveBeenCalled();
  });

  it("processes files smaller than 500 KB in dev mode source map generation", async () => {
    vi.mocked(fs.readdir).mockResolvedValue(["component.js"] as any);
    vi.mocked(fs.stat).mockResolvedValue({ size: 40 * 1024 } as any); // 40 KB < threshold
    const writeMock = vi.fn();
    vi.mocked(sorcery.load).mockResolvedValue({ write: writeMock } as any);

    await generate(devCtx as unknown as ResolvedEmbeddableConfig, "sdk-react");
    buildFinishCallback!();
    await flushPromises();

    expect(sorcery.load).toHaveBeenCalled();
    expect(writeMock).toHaveBeenCalled();
  });

  it("does not check file size in non-dev (prod) build", async () => {
    vi.mocked(fs.readdir).mockResolvedValue(["bundle.js"] as any);
    vi.mocked(createCompiler).mockResolvedValue({
      build: vi.fn().mockResolvedValue({ hasError: false }),
      destroy: vi.fn(),
      createWatcher: vi.fn(),
    } as any);

    await generate(config as unknown as ResolvedEmbeddableConfig, "sdk-react");

    // fs.stat is not called for the size check in non-dev (prod) mode
    expect(fs.stat).not.toHaveBeenCalled();
    // sorcery.load is still called — the size check is bypassed entirely in prod
    expect(sorcery.load).toHaveBeenCalled();
  });
});
