import { doNotThrowTransformError } from "@nestia/core";
import fs from "fs";
import path from "path";
import { pathToFileURL } from "url";

import { INestiaConfig } from "../../INestiaConfig";
import { EmittedJavaScriptPatcher } from "../../utils/EmittedJavaScriptPatcher";
import { TsConfigReader } from "../../utils/TsConfigReader";
import { TtscExecutor } from "../../utils/TtscExecutor";

export namespace NestiaConfigLoader {
  export interface ICompilerOptions {
    raw: {
      compilerOptions?: Record<string, any>;
    };
  }

  export const compilerOptions = async (
    project: string,
  ): Promise<ICompilerOptions> => {
    const configFileName = findConfigFile(process.cwd(), project);
    if (!configFileName) throw new Error(`unable to find "${project}" file.`);
    const tsconfig = await TsConfigReader.read(configFileName);
    return {
      raw: {
        compilerOptions:
          typeof tsconfig?.compilerOptions === "object"
            ? (tsconfig.compilerOptions as Record<string, any>)
            : {},
      },
    };
  };

  export const configurations = async (
    file: string,
    compilerOptions: Record<string, any>,
  ): Promise<INestiaConfig[]> => {
    if (fs.existsSync(path.resolve(file)) === false)
      throw new Error(`Unable to find "${file}" file.`);

    doNotThrowTransformError(false);

    if (compilerOptions.plugins !== undefined)
      assertPlugins(file, compilerOptions.plugins);

    const configFile: string = await materializeConfiguration({
      file,
      compilerOptions,
    });
    const loaded: unknown = await loadMaterializedModule(configFile);
    const instance: INestiaConfig | INestiaConfig[] = extractConfiguration(
      file,
      loaded,
    );
    const configurations: INestiaConfig[] = Array.isArray(instance)
      ? instance
      : [instance];

    return assertConfigurations(file, configurations);
  };

  const MATERIALIZED_ROOTS: Set<string> = new Set();
  let CLEANUP_REGISTERED: boolean = false;

  const materializeConfiguration = async (props: {
    file: string;
    compilerOptions: Record<string, any>;
  }): Promise<string> => {
    const configFile: string = path.resolve(props.file);
    const project: string = process.env.NESTIA_PROJECT ?? "tsconfig.json";
    const projectFile: string | undefined = findConfigFile(
      process.cwd(),
      project,
    );
    if (projectFile === undefined)
      throw new Error(`unable to find "${project}" file.`);

    const projectRoot: string = path.dirname(path.resolve(projectFile));
    const wrapperRoot: string = fs.mkdtempSync(
      path.join(ensureMaterializedRoot(projectRoot), "tsconfig-"),
    );
    const outputRoot: string = fs.mkdtempSync(
      path.join(ensureMaterializedRoot(projectRoot), "run-"),
    );
    const wrapperFile: string = path.join(wrapperRoot, "tsconfig.json");
    const wrapperConfig = {
      extends: projectFile,
      compilerOptions: {
        noEmit: false,
        noUnusedLocals: false,
        noUnusedParameters: false,
        ...nodeAmbientCompilerOptions(projectRoot, props.compilerOptions),
        // The wrapper compiles the config file only to require() its JS; .d.ts
        // is never read, and tsgo's declaration emitter can nil-panic on a
        // config that calls into Nest. Force it off regardless of the project.
        declaration: false,
        declarationMap: false,
        outDir: outputRoot,
        plugins: materializePlugins(props.compilerOptions.plugins),
        rootDir: projectRoot,
      },
      include: [configFile],
      exclude: [path.join(projectRoot, "src", "test", "**", "*")],
    };
    fs.writeFileSync(
      wrapperFile,
      JSON.stringify(wrapperConfig, null, 2),
      "utf8",
    );

    try {
      TtscExecutor.run({
        cwd: projectRoot,
        env: sdkTransformEnv(),
        project: wrapperFile,
      });
    } catch (error) {
      const stderr: string = readChildOutput(error, "stderr");
      const stdout: string = readChildOutput(error, "stdout");
      const detail: string = stderr || stdout;
      const cause: Error =
        error instanceof Error ? error : new Error(String(error));
      const status: number | string | undefined =
        (cause as { status?: number }).status ??
        (cause as NodeJS.ErrnoException).code;
      throw new Error(
        detail
          ? `failed to compile "${props.file}" through ttsc:\n${detail}`
          : `failed to compile "${props.file}" through ttsc (exit code ${status ?? "unknown"}). Run \`npx ttsc -p ${projectFile}\` to see the underlying diagnostics.`,
        { cause },
      );
    } finally {
      fs.rmSync(wrapperRoot, { force: true, recursive: true });
    }
    await EmittedJavaScriptPatcher.importMetaUrl(outputRoot);

    const configKey: string = emittedJavaScriptKey(projectRoot, configFile);
    const next: string = path.join(outputRoot, configKey);
    if (fs.existsSync(next) === false)
      throw new Error(
        `failed to materialize "${props.file}" through ttsc native transform.`,
      );
    return next;
  };

  const ensureMaterializedRoot = (projectRoot: string): string => {
    const root: string = path.join(
      projectRoot,
      "node_modules",
      ".nestia",
      "config-loader",
    );
    fs.mkdirSync(root, { recursive: true });
    MATERIALIZED_ROOTS.add(root);
    if (CLEANUP_REGISTERED === false) {
      CLEANUP_REGISTERED = true;
      const sweep = (): void => {
        for (const location of MATERIALIZED_ROOTS)
          fs.rmSync(location, { force: true, recursive: true });
      };
      process.once("exit", sweep);
      // process.once("exit", …) does not fire on SIGINT/SIGTERM. Without
      // these handlers a Ctrl-C during codegen leaves `run-*` and
      // `tsconfig-*` mkdtempSync directories behind under
      // node_modules/.nestia/config-loader/ until a subsequent clean exit.
      // The module-level CLEANUP_REGISTERED flag above guards against
      // re-entrancy within this module; we deliberately do NOT gate on
      // `process.listenerCount(signal) > 0` because the parallel sweep in
      // `ConfigAnalyzer.ensureRuntimeCleanup` (or any user-app SIGINT
      // handler) could register first, and that gate would skip our
      // registration — leaving MATERIALIZED_ROOTS unswept on Ctrl-C.
      // Windows note: `process.kill(pid, "SIGINT")` calls TerminateProcess
      // rather than re-raising; whichever module registers FIRST runs its
      // sweep, the second is skipped, RUNTIME/MATERIALIZED cleanup is
      // best-effort on Windows. SIGHUP is a no-op for most common code
      // paths on Windows (Node fires it on console-close and exits within
      // seconds).
      const onSignal = (signal: NodeJS.Signals): void => {
        sweep();
        process.kill(process.pid, signal);
      };
      for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
        process.once(signal, onSignal);
      }
    }
    return root;
  };

  const emittedJavaScriptKey = (projectRoot: string, file: string): string => {
    const relative: string = path.relative(projectRoot, file);
    const extension: string = path.extname(relative).toLowerCase();
    const emitted: string =
      extension === ".mts" ? ".mjs" : extension === ".cts" ? ".cjs" : ".js";
    return path
      .join(
        path.dirname(relative),
        `${path.basename(relative, extension)}${emitted}`,
      )
      .split(path.sep)
      .join(path.posix.sep);
  };

  const nodeAmbientCompilerOptions = (
    projectRoot: string,
    compilerOptions: Record<string, any>,
  ): { typeRoots?: string[]; types: string[] } => {
    const typeRoots: string[] = uniqueStrings([
      ...asStringArray(compilerOptions.typeRoots),
      ...resolveNodeTypeRoots(projectRoot),
    ]);
    const types: string[] = uniqueStrings([
      "node",
      ...asStringArray(compilerOptions.types).filter((value) => value !== "*"),
    ]);
    return {
      ...(typeRoots.length !== 0 ? { typeRoots } : {}),
      types,
    };
  };

  const resolveNodeTypeRoots = (projectRoot: string): string[] => {
    const roots: string[] = [];
    for (const base of [projectRoot, process.cwd(), __dirname])
      try {
        const location: string = require.resolve("@types/node/package.json", {
          paths: [base],
        });
        roots.push(path.dirname(path.dirname(location)));
      } catch {
        continue;
      }
    return roots;
  };

  const asStringArray = (input: unknown): string[] =>
    Array.isArray(input)
      ? input.filter((elem): elem is string => typeof elem === "string")
      : [];

  const uniqueStrings = (input: string[]): string[] => [...new Set(input)];

  type MaterializePlugin = Record<string, unknown> & { transform?: unknown };

  const sdkTransformEnv = (): NodeJS.ProcessEnv =>
    process.env.NESTIA_SDK_TRANSFORM === undefined
      ? { NESTIA_SDK_TRANSFORM: "1" }
      : {};

  const materializePlugins = (input: unknown): MaterializePlugin[] => {
    const plugins: MaterializePlugin[] = Array.isArray(input)
      ? input
          .filter((p) => typeof p === "object" && p !== null)
          .map((p) => ({ ...(p as MaterializePlugin) }))
      : [];
    const typia: MaterializePlugin | undefined = plugins.find((p) =>
      isTransform(p, "typia"),
    );
    const core: MaterializePlugin | undefined = plugins.find((p) =>
      isTransform(p, "@nestia/core"),
    );
    return [
      {
        ...(typia ?? {}),
        transform: "typia/lib/transform",
        enabled: false,
      },
      normalizePlugin({
        ...(core ?? {}),
        transform: "@nestia/core/native/transform.cjs",
      }),
    ];
  };

  const normalizePlugin = (plugin: MaterializePlugin): MaterializePlugin => {
    const output: MaterializePlugin = { ...plugin };
    if (output.enabled === false) delete output.enabled;
    return output;
  };

  const isTransform = (plugin: MaterializePlugin, name: string): boolean =>
    typeof plugin.transform === "string" && plugin.transform.includes(name);

  const findConfigFile = (cwd: string, project: string): string | undefined => {
    const candidate: string = path.isAbsolute(project)
      ? project
      : path.resolve(cwd, project);
    if (fs.existsSync(candidate)) return candidate;
    if (path.isAbsolute(project) || project.includes(path.sep))
      return undefined;

    let current: string = path.resolve(cwd);
    while (true) {
      const next: string = path.join(current, project);
      if (fs.existsSync(next)) return next;
      const parent: string = path.dirname(current);
      if (parent === current) return undefined;
      current = parent;
    }
  };

  const extractConfiguration = (
    file: string,
    loaded: unknown,
  ): INestiaConfig | INestiaConfig[] => {
    const candidates: unknown[] = [];
    const collect = (value: unknown): void => {
      if (isObject(value)) {
        candidates.push(value.default);
        candidates.push(value.NESTIA_CONFIG);
        if (isObject(value.default)) {
          candidates.push(value.default.default);
          candidates.push(value.default.NESTIA_CONFIG);
        }
      }
      candidates.push(value);
    };
    collect(loaded);
    const matched: unknown = candidates.find(
      (value) =>
        Array.isArray(value) ||
        (isObject(value) && Object.hasOwn(value, "input")),
    );
    if (matched === undefined)
      throw new Error(
        `invalid "${file}" data: configuration must be exported.`,
      );
    return matched as INestiaConfig | INestiaConfig[];
  };

  const loadMaterializedModule = async (file: string): Promise<unknown> => {
    if (file.endsWith(".mjs")) {
      const dynamicImport = new Function(
        "specifier",
        "return import(specifier)",
      ) as (specifier: string) => Promise<unknown>;
      return dynamicImport(pathToFileURL(file).href);
    }
    return require(file);
  };

  const assertPlugins = (
    file: string,
    input: unknown,
  ): Array<Record<string, any>> => {
    if (
      Array.isArray(input) &&
      input.every((elem) => typeof elem === "object" && elem !== null)
    )
      return input as Array<Record<string, any>>;
    throw new Error(
      `invalid "${file}" data: compilerOptions.plugins must be an array.`,
    );
  };

  const assertConfigurations = (
    file: string,
    input: unknown[],
  ): INestiaConfig[] => {
    input.forEach((config, index) => assertConfig(file, config, index));
    return input as INestiaConfig[];
  };

  const assertConfig = (file: string, input: unknown, index: number): void => {
    if (isObject(input) === false)
      throw new Error(
        `invalid "${file}" data: configuration #${index} must be an object.`,
      );
    const config: INestiaConfig = input as unknown as INestiaConfig;
    if (isInput(config.input) === false)
      throw new Error(
        `invalid "${file}" data: configuration #${index}.input is invalid.`,
      );
    for (const [key, value] of [
      ["output", config.output],
      ["distribute", config.distribute],
      ["e2e", config.e2e],
    ] as const)
      if (value !== undefined && typeof value !== "string")
        throw new Error(
          `invalid "${file}" data: configuration #${index}.${key} must be a string.`,
        );
    for (const [key, value] of [
      ["keyword", config.keyword],
      ["simulate", config.simulate],
      ["propagate", config.propagate],
      ["clone", config.clone],
      ["primitive", config.primitive],
      ["assert", config.assert],
      ["json", config.json],
    ] as const)
      if (value !== undefined && typeof value !== "boolean")
        throw new Error(
          `invalid "${file}" data: configuration #${index}.${key} must be a boolean.`,
        );
    if (config.swagger !== undefined && isSwagger(config.swagger) === false)
      throw new Error(
        `invalid "${file}" data: configuration #${index}.swagger is invalid.`,
      );
  };

  const isInput = (input: unknown): input is INestiaConfig["input"] => {
    if (
      typeof input === "string" ||
      typeof input === "function" ||
      isStringArray(input)
    )
      return true;
    if (isObject(input) === false) return false;
    return (
      isStringArray(input.include) &&
      (input.exclude === undefined || isStringArray(input.exclude))
    );
  };

  const isSwagger = (input: unknown): input is INestiaConfig.ISwaggerConfig => {
    if (isObject(input) === false) return false;
    return (
      typeof input.output === "string" &&
      (input.openapi === undefined ||
        ["2.0", "3.0", "3.1", "3.2"].includes(input.openapi as string)) &&
      (input.beautify === undefined ||
        typeof input.beautify === "boolean" ||
        typeof input.beautify === "number") &&
      (input.additional === undefined ||
        typeof input.additional === "boolean") &&
      (input.decompose === undefined || typeof input.decompose === "boolean") &&
      (input.operationId === undefined ||
        typeof input.operationId === "function")
    );
  };

  const isObject = (input: unknown): input is Record<string, unknown> =>
    typeof input === "object" &&
    input !== null &&
    Array.isArray(input) === false;

  const isStringArray = (input: unknown): input is string[] =>
    Array.isArray(input) && input.every((elem) => typeof elem === "string");

  const readChildOutput = (
    error: unknown,
    key: "stderr" | "stdout",
  ): string => {
    if (!error || typeof error !== "object" || !(key in error)) return "";
    const value = (error as Record<string, unknown>)[key];
    if (value === null || value === undefined) return "";
    if (typeof value === "string") return value.trim();
    if (Buffer.isBuffer(value)) return value.toString("utf8").trim();
    return "";
  };
}
