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

import { resolveSourceRoot } from "./config/source-root.ts";

/**
 * Stages a framework's standalone output (e.g. Next.js `.next/standalone`)
 * into a deployable artifact directory, materializing the symlinks the output
 * carries into the workspace `node_modules` and hoisting isolated-store
 * dependencies (pnpm `.pnpm`, bun `.bun`) so Node resolution works once the
 * artifact is unpacked elsewhere.
 *
 * Location-agnostic: the workspace boundary is resolved from `appPath`, not
 * the current working directory.
 */
export async function stageStandaloneArtifact(options: {
  standaloneDir: string;
  artifactDir: string;
  appPath: string;
  signal?: AbortSignal;
}): Promise<void> {
  const standaloneRoot = path.resolve(options.standaloneDir);
  const artifactRoot = path.resolve(options.artifactDir);
  const appRoot = path.resolve(options.appPath);
  const sourceRoot = await resolveSourceRoot(appRoot, options.signal);

  await copyPathMaterializingSymlinks(standaloneRoot, artifactRoot, {
    standaloneRoot,
    destinationRoot: artifactRoot,
    appRoot,
    sourceRoot,
    signal: options.signal,
  });
  await hoistIsolatedStoreDependencies(
    path.join(artifactRoot, "node_modules"),
    options.signal,
  );
}

/** Options shared by the symlink-materializing copy walk. */
export interface MaterializeOptions {
  /** Root of the standalone output being copied (for fallback resolution). */
  standaloneRoot?: string;
  /**
   * Where `standaloneRoot` is being copied to. When set, symlinks whose target
   * resolves inside `standaloneRoot` are recreated as relative symlinks in the
   * destination instead of materialized: bun/pnpm isolated stores resolve a
   * package's dependencies through the link's *location* in the store, which
   * materializing destroys. Only out-of-tree targets are safe to materialize.
   */
  destinationRoot?: string;
  /** The app directory; symlink targets here are materialized. */
  appRoot: string;
  /** The workspace/source root; targets in its node_modules are materialized. */
  sourceRoot: string;
  /**
   * Roots a symlink may resolve into. Defaults to the app root and workspace
   * node_modules, matching standalone framework artifacts.
   */
  allowedSymlinkTargetRoots?: readonly string[];
  /** Ignore a missing source path instead of throwing. Useful for trace lists. */
  ignoreMissingSource?: boolean;
  signal?: AbortSignal;
}

/**
 * pnpm and bun (isolated linker) both keep packages in a virtual store with a
 * shared symlink farm (`.pnpm/node_modules`, `.bun/node_modules`). Hoist the
 * farm entries to the artifact's `node_modules` root so Node-style resolution
 * works after symlinks are materialized.
 */
export async function hoistIsolatedStoreDependencies(
  nodeModulesDir: string,
  signal?: AbortSignal,
): Promise<void> {
  await hoistStoreDependencies(
    nodeModulesDir,
    path.join(nodeModulesDir, ".pnpm", "node_modules"),
    signal,
  );
  await hoistStoreDependencies(
    nodeModulesDir,
    path.join(nodeModulesDir, ".bun", "node_modules"),
    signal,
  );
}

async function hoistStoreDependencies(
  nodeModulesDir: string,
  storeNodeModulesDir: string,
  signal?: AbortSignal,
): Promise<void> {
  if (!(await directoryExists(storeNodeModulesDir, signal))) {
    return;
  }

  const entries = await readEntries(storeNodeModulesDir, signal);
  for (const entry of entries) {
    const sourcePath = path.join(storeNodeModulesDir, entry.name);

    if (entry.name.startsWith("@") && entry.isDirectory()) {
      const scopedEntries = await readEntries(sourcePath, signal);
      for (const scopedEntry of scopedEntries) {
        const scopedDestination = path.join(
          nodeModulesDir,
          entry.name,
          scopedEntry.name,
        );
        if (await pathExists(scopedDestination, signal)) {
          continue;
        }
        signal?.throwIfAborted();
        await mkdir(path.dirname(scopedDestination), { recursive: true });
        await copyPathMaterializingSymlinks(
          path.join(sourcePath, scopedEntry.name),
          scopedDestination,
          storeMaterializeOptions(storeNodeModulesDir, nodeModulesDir, signal),
        );
      }
      continue;
    }

    const destinationPath = path.join(nodeModulesDir, entry.name);
    if (await pathExists(destinationPath, signal)) {
      continue;
    }

    await copyPathMaterializingSymlinks(
      sourcePath,
      destinationPath,
      storeMaterializeOptions(storeNodeModulesDir, nodeModulesDir, signal),
    );
  }
}

function storeMaterializeOptions(
  storeNodeModulesDir: string,
  nodeModulesDir: string,
  signal?: AbortSignal,
): MaterializeOptions {
  return {
    standaloneRoot: storeNodeModulesDir,
    appRoot: nodeModulesDir,
    sourceRoot: nodeModulesDir,
    signal,
  };
}

export async function copyPathMaterializingSymlinks(
  sourcePath: string,
  destinationPath: string,
  options: MaterializeOptions,
): Promise<void> {
  options.signal?.throwIfAborted();
  const sourceStat = await lstat(sourcePath).catch((error) => {
    if (options.ignoreMissingSource && isENOENT(error)) {
      return null;
    }
    throw error;
  });
  if (!sourceStat) {
    return;
  }

  if (sourceStat.isSymbolicLink()) {
    const resolvedTarget = await resolveSymlinkTarget(sourcePath, options);
    if (resolvedTarget === null) {
      return;
    }
    if (
      options.standaloneRoot &&
      options.destinationRoot &&
      isPathWithin(options.standaloneRoot, resolvedTarget)
    ) {
      const destinationTarget = path.join(
        options.destinationRoot,
        path.relative(options.standaloneRoot, resolvedTarget),
      );
      await mkdir(path.dirname(destinationPath), { recursive: true });
      await symlink(
        path.relative(path.dirname(destinationPath), destinationTarget) || ".",
        destinationPath,
      );
      return;
    }
    await copyPathMaterializingSymlinks(
      resolvedTarget,
      destinationPath,
      options,
    );
    return;
  }

  if (sourceStat.isDirectory()) {
    await mkdir(destinationPath, { recursive: true });
    const entries = await readEntries(sourcePath, options.signal);
    for (const entry of entries) {
      await copyPathMaterializingSymlinks(
        path.join(sourcePath, entry.name),
        path.join(destinationPath, entry.name),
        options,
      );
    }
    return;
  }

  if (sourceStat.isFile()) {
    await mkdir(path.dirname(destinationPath), { recursive: true });
    await copyFile(sourcePath, destinationPath);
    await chmod(destinationPath, sourceStat.mode);
  }
}

async function resolveSymlinkTarget(
  symlinkPath: string,
  options: MaterializeOptions,
): Promise<string | null> {
  options.signal?.throwIfAborted();
  const linkTarget = await readlink(symlinkPath);
  const resolvedTarget = path.resolve(path.dirname(symlinkPath), linkTarget);

  if (await pathExists(resolvedTarget, options.signal)) {
    const allowedRoots = options.allowedSymlinkTargetRoots ?? [
      options.appRoot,
      path.join(options.sourceRoot, "node_modules"),
    ];
    if (!allowedRoots.some((root) => isPathWithin(root, resolvedTarget))) {
      throw new Error(
        `Build artifact symlink escapes the app directory: ${resolvedTarget}`,
      );
    }
    return resolvedTarget;
  }

  if (
    options.standaloneRoot &&
    isPathWithin(options.standaloneRoot, resolvedTarget)
  ) {
    const fallbackTarget = path.join(
      options.appRoot,
      path.relative(options.standaloneRoot, resolvedTarget),
    );
    if (await pathExists(fallbackTarget, options.signal)) {
      return fallbackTarget;
    }
  }

  // pnpm's hoist layer (.pnpm/node_modules/*) contains speculative links that
  // are routinely dangling — Next's tracer doesn't always align with what pnpm
  // populated. Drop these silently. Dangling links elsewhere still throw so a
  // real missing dep doesn't get masked.
  if (isPnpmHoistLink(symlinkPath)) {
    return null;
  }

  throw new Error(
    `Build artifact symlink target is missing: ${symlinkPath} -> ${linkTarget} (resolved to ${resolvedTarget})`,
  );
}

function isPnpmHoistLink(symlinkPath: string): boolean {
  const parts = path.dirname(symlinkPath).split(path.sep);
  for (let i = 0; i < parts.length - 1; i++) {
    if (parts[i] === ".pnpm" && parts[i + 1] === "node_modules") {
      return true;
    }
  }
  return false;
}

export function isPathWithin(rootPath: string, candidatePath: string): boolean {
  const relativePath = path.relative(rootPath, candidatePath);
  return (
    relativePath === "" ||
    (!relativePath.startsWith(`..${path.sep}`) &&
      relativePath !== ".." &&
      !path.isAbsolute(relativePath))
  );
}

async function readEntries(directory: string, signal?: AbortSignal) {
  signal?.throwIfAborted();
  return readdir(directory, { withFileTypes: true });
}

async function pathExists(
  targetPath: string,
  signal?: AbortSignal,
): Promise<boolean> {
  signal?.throwIfAborted();
  try {
    await stat(targetPath);
    return true;
  } catch {
    return false;
  }
}

function isENOENT(error: unknown): boolean {
  return (
    error instanceof Error &&
    "code" in error &&
    (error as { code?: string }).code === "ENOENT"
  );
}

async function directoryExists(
  targetPath: string,
  signal?: AbortSignal,
): Promise<boolean> {
  signal?.throwIfAborted();
  try {
    return (await stat(targetPath)).isDirectory();
  } catch {
    return false;
  }
}
