import {
  chmod,
  copyFile,
  lstat,
  mkdir,
  mkdtemp,
  readFile,
  readlink,
  rm,
  stat,
  symlink,
} from "node:fs/promises";
import os from "node:os";
import path from "node:path";

import { nodeFileTrace } from "@vercel/nft";

import {
  type BuildSettings,
  joinPosix,
  resolveBuildSettings,
} from "./build-settings.ts";
import type { BuildArtifact, BuildStrategy } from "./build-strategy.ts";
import {
  hasPackageDependency,
  hasRootFile,
  runBuildSettingsCommand,
} from "./build-strategy.ts";
import { defaultHttpPortForBuildType } from "./config/frameworks.ts";
import { resolveSourceRoot } from "./config/source-root.ts";
import { type BuildCommandIo, readPackageManifest } from "./workspace.ts";

const NEST_CLI_FILENAME = "nest-cli.json";

/** Compiled entrypoints probed, in order, when config does not resolve one. */
const DEFAULT_COMPILED_ENTRYPOINTS = ["dist/src/main.js", "dist/main.js"];

/**
 * Build strategy for NestJS applications. Runs the user's build (so `nest build`
 * and any `prisma generate` in the `build` script run), resolves the compiled
 * entrypoint, then stages a lean artifact by file-tracing that entrypoint with
 * `@vercel/nft`: it ships only the compiled output and the `node_modules` files
 * actually reachable, not the full dependency tree. NestJS has no Next-style
 * standalone output, so the trace is the lean equivalent that keeps the
 * artifact under the compute runtime's disk ceiling.
 */
export class NestjsBuild implements BuildStrategy {
  readonly #appPath: string;
  readonly #buildSettings?: BuildSettings;
  readonly #io?: BuildCommandIo;

  constructor(options: {
    appPath: string;
    buildSettings?: BuildSettings;
    io?: BuildCommandIo;
  }) {
    this.#appPath = options.appPath;
    this.#buildSettings = options.buildSettings;
    this.#io = options.io;
  }

  async canBuild(signal?: AbortSignal): Promise<boolean> {
    return (
      (await hasRootFile(this.#appPath, [NEST_CLI_FILENAME], signal)) ||
      (await hasPackageDependency(this.#appPath, ["@nestjs/core"], signal))
    );
  }

  async execute(signal?: AbortSignal): Promise<BuildArtifact> {
    signal?.throwIfAborted();

    const settings =
      this.#buildSettings ??
      (await resolveBuildSettings({
        appPath: this.#appPath,
        buildType: "nestjs",
        signal,
      }));

    await runBuildSettingsCommand({
      appPath: this.#appPath,
      settings,
      failurePrefix: "NestJS",
      defaultCli: {
        source: "NestJS default",
        cliName: "nest",
        args: ["build"],
        missingMessage:
          "Could not find the Nest CLI. Add a `build` script to package.json, install @nestjs/cli, or ensure npx/bunx is available.",
      },
      io: this.#io,
      signal,
    });

    signal?.throwIfAborted();
    const compiledEntry = await this.#resolveCompiledEntrypoint(
      settings,
      signal,
    );

    const outDir = await mkdtemp(path.join(os.tmpdir(), "compute-build-"));
    try {
      const artifactDir = path.join(outDir, "app");
      const entrypoint = await stageTracedArtifact({
        appPath: this.#appPath,
        artifactDir,
        compiledEntry,
        signal,
      });

      return {
        directory: artifactDir,
        entrypoint,
        defaultPortMapping: { http: defaultHttpPortForBuildType("nestjs") },
        cleanup: () => rm(outDir, { recursive: true, force: true }),
      };
    } catch (error) {
      await rm(outDir, { recursive: true, force: true });
      throw error;
    }
  }

  /**
   * Resolves the compiled entrypoint relative to the app root, using posix
   * separators. Prefers `package.json` `main`, then the path computed from
   * `nest-cli.json` and the tsconfig `outDir`, then the common defaults.
   */
  async #resolveCompiledEntrypoint(
    settings: BuildSettings,
    signal?: AbortSignal,
  ): Promise<string> {
    const candidates: string[] = [];

    if (settings.entrypoint) {
      candidates.push(joinPosix(settings.outputDirectory, settings.entrypoint));
    }

    const fromMain = await this.#mainFieldEntrypoint(signal);
    if (fromMain) {
      candidates.push(fromMain);
    }

    const fromConfig = await this.#configuredCompiledEntrypoint(signal);
    if (fromConfig) {
      candidates.push(fromConfig);
    }

    candidates.push(...compiledEntrypointDefaults(settings.outputDirectory));

    for (const candidate of candidates) {
      signal?.throwIfAborted();
      const normalized = normalizeRelative(candidate);
      if (!normalized) {
        continue;
      }
      if (await isFile(path.join(this.#appPath, normalized))) {
        return normalized;
      }
    }

    throw new Error(
      `NestJS build did not produce a compiled entrypoint. Looked for ${candidates.join(", ")} under ${this.#appPath}. ` +
        "Ensure `nest build` ran and check the `main` field, nest-cli.json, or tsconfig outDir.",
    );
  }

  async #mainFieldEntrypoint(signal?: AbortSignal): Promise<string | null> {
    const manifest = await readPackageManifest(this.#appPath, signal);
    return typeof manifest?.main === "string" && manifest.main.trim().length > 0
      ? manifest.main.trim()
      : null;
  }

  async #configuredCompiledEntrypoint(
    signal?: AbortSignal,
  ): Promise<string | null> {
    const nestCli = await readNestCliConfig(this.#appPath, signal);
    const sourceRoot = nestCli.sourceRoot ?? "src";
    const entryFile = nestCli.entryFile ?? "main";
    const outDir = await readTsconfigOutDir(this.#appPath, signal);

    const relativeEntry = path.posix.join(
      path.posix.relative(".", sourceRoot) || ".",
      `${entryFile}.js`,
    );
    return joinPosix(outDir, relativeEntry);
  }
}

function compiledEntrypointDefaults(outputDirectory: string): string[] {
  if (outputDirectory === "dist") {
    return DEFAULT_COMPILED_ENTRYPOINTS;
  }
  return [
    joinPosix(outputDirectory, "src/main.js"),
    joinPosix(outputDirectory, "main.js"),
    ...DEFAULT_COMPILED_ENTRYPOINTS,
  ];
}

interface NestCliConfig {
  sourceRoot?: string;
  entryFile?: string;
}

async function readNestCliConfig(
  appPath: string,
  signal?: AbortSignal,
): Promise<NestCliConfig> {
  signal?.throwIfAborted();
  let content: string;
  try {
    content = await readFile(path.join(appPath, NEST_CLI_FILENAME), {
      encoding: "utf-8",
      signal,
    });
  } catch (error) {
    if (signal?.aborted) throw error;
    return {};
  }

  try {
    const parsed = JSON.parse(content) as {
      sourceRoot?: unknown;
      entryFile?: unknown;
    };
    return {
      sourceRoot:
        typeof parsed.sourceRoot === "string" ? parsed.sourceRoot : undefined,
      entryFile:
        typeof parsed.entryFile === "string" ? parsed.entryFile : undefined,
    };
  } catch {
    return {};
  }
}

const TSCONFIG_FILENAMES = ["tsconfig.build.json", "tsconfig.json"] as const;

/** Best-effort read of `compilerOptions.outDir`, defaulting to `dist`. */
async function readTsconfigOutDir(
  appPath: string,
  signal?: AbortSignal,
): Promise<string> {
  for (const fileName of TSCONFIG_FILENAMES) {
    signal?.throwIfAborted();
    let content: string;
    try {
      content = await readFile(path.join(appPath, fileName), {
        encoding: "utf-8",
        signal,
      });
    } catch (error) {
      if (signal?.aborted) throw error;
      continue;
    }
    const outDir = parseTsconfigOutDir(content);
    if (outDir) {
      return outDir;
    }
  }
  return "dist";
}

/** Reads `compilerOptions.outDir` from a tsconfig source, tolerating comments. */
function parseTsconfigOutDir(content: string): string | null {
  try {
    const parsed = JSON.parse(stripJsonComments(content)) as {
      compilerOptions?: { outDir?: unknown };
    };
    const outDir = parsed.compilerOptions?.outDir;
    if (typeof outDir !== "string") {
      return null;
    }
    return normalizeRelative(outDir);
  } catch {
    return null;
  }
}

/**
 * Stages the compiled entrypoint and its transitive dependencies into
 * `artifactDir` by tracing with `@vercel/nft`, then copying only the traced
 * paths. Returns the artifact-root-relative entrypoint to run with `node`.
 *
 * The trace base is the source root, not the app dir, because nft ignores
 * everything above `base`: in a workspace, deps hoisted to the repo root sit
 * above the app and would be dropped, leaving the artifact with no usable
 * `node_modules`. Tracing from the source root keeps the workspace-relative
 * layout (e.g. `apps/api/dist/main.js` plus root and app-local `node_modules`),
 * so `node <dir>/<entrypoint>` resolves deps by walking up. For a single-app
 * repo the source root equals the app dir, so the entrypoint stays `dist/main.js`.
 *
 * The trace lists both the visible `node_modules/<pkg>` symlinks and the real
 * files they resolve to (e.g. pnpm `.pnpm/<pkg>` / bun `.bun/<pkg>` stores), so
 * symlinks are recreated verbatim and the store files are copied alongside.
 */
async function stageTracedArtifact(options: {
  appPath: string;
  artifactDir: string;
  compiledEntry: string;
  signal?: AbortSignal;
}): Promise<string> {
  const appPath = path.resolve(options.appPath);
  const sourceRoot = await resolveSourceRoot(appPath, options.signal);
  const entry = path.join(appPath, options.compiledEntry);
  const entrypoint = path.posix.normalize(
    path.relative(sourceRoot, entry).split(path.sep).join("/"),
  );

  options.signal?.throwIfAborted();
  const { fileList } = await nodeFileTrace([entry], { base: sourceRoot });

  await mkdir(options.artifactDir, { recursive: true });
  for (const relativePath of fileList) {
    options.signal?.throwIfAborted();
    await stageTracedPath(
      path.join(sourceRoot, relativePath),
      path.join(options.artifactDir, relativePath),
    );
  }

  return entrypoint;
}

/** Copies one traced path, preserving symlinks and file mode. */
async function stageTracedPath(
  sourcePath: string,
  destinationPath: string,
): Promise<void> {
  const info = await lstat(sourcePath).catch(() => null);
  if (!info) {
    return;
  }

  await mkdir(path.dirname(destinationPath), { recursive: true });

  if (info.isSymbolicLink()) {
    await symlink(await readlink(sourcePath), destinationPath);
    return;
  }

  if (info.isFile()) {
    await copyFile(sourcePath, destinationPath);
    await chmod(destinationPath, info.mode);
  }
}

function normalizeRelative(value: string): string | null {
  const raw = value.trim().replace(/\\/g, "/");
  if (raw.length === 0) {
    return null;
  }
  const normalized = path.posix.normalize(raw);
  if (
    path.posix.isAbsolute(normalized) ||
    normalized === ".." ||
    normalized.startsWith("../")
  ) {
    return null;
  }
  return normalized === "." ? null : normalized;
}

/**
 * Removes `//` line comments and block comments from JSONC, tracking
 * string state so a `//` inside a string value (e.g. `"https://..."`) is
 * preserved. A naive regex strips such content and mis-reads the surrounding
 * keys; on any malformed input the caller falls back to the default outDir.
 */
function stripJsonComments(content: string): string {
  let result = "";
  let inString = false;
  let escaped = false;

  for (let i = 0; i < content.length; i++) {
    const char = content[i];

    if (inString) {
      result += char;
      if (escaped) {
        escaped = false;
      } else if (char === "\\") {
        escaped = true;
      } else if (char === '"') {
        inString = false;
      }
      continue;
    }

    if (char === '"') {
      inString = true;
      result += char;
      continue;
    }

    const next = content[i + 1];
    if (char === "/" && next === "/") {
      while (i < content.length && content[i] !== "\n") {
        i++;
      }
      if (i < content.length) {
        result += content[i];
      }
      continue;
    }
    if (char === "/" && next === "*") {
      i += 2;
      while (
        i < content.length &&
        !(content[i] === "*" && content[i + 1] === "/")
      ) {
        i++;
      }
      i++;
      continue;
    }

    result += char;
  }

  return result;
}

async function isFile(targetPath: string): Promise<boolean> {
  const info = await stat(targetPath).catch(() => null);
  return info?.isFile() ?? false;
}
