import { cp, mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";

import { stageStandaloneArtifact } from "./artifact-stage.ts";
import {
  type BuildSettings,
  nextOutputRootFromStandaloneDirectory,
  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 { resolveConfiguredArtifactEntrypoint } from "./configured-artifact.ts";
import type { BuildCommandIo } from "./workspace.ts";

const NEXT_CONFIG_FILENAMES = [
  "next.config.js",
  "next.config.mjs",
  "next.config.ts",
  "next.config.mts",
];

/**
 * Build strategy for Next.js applications. Runs the resolved build command
 * (the user's `package.json` build script, or `next build`), then stages the
 * `output: "standalone"` tree — materializing workspace symlinks and hoisting
 * isolated-store dependencies. Apps built without standalone output fall back
 * to packaging the full tree and serving with `next start`, unless
 * `requireStandalone` is set (a disk-limited deploy target opts out of that).
 */
export class NextjsBuild implements BuildStrategy {
  readonly #appPath: string;
  readonly #buildSettings?: BuildSettings;
  readonly #io?: BuildCommandIo;
  readonly #requireStandalone: boolean;

  constructor(options: {
    appPath: string;
    buildSettings?: BuildSettings;
    io?: BuildCommandIo;
    /** Fail instead of falling back to the full-tree `next start` artifact. */
    requireStandalone?: boolean;
  }) {
    this.#appPath = options.appPath;
    this.#buildSettings = options.buildSettings;
    this.#io = options.io;
    this.#requireStandalone = options.requireStandalone ?? false;
  }

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

  async execute(signal?: AbortSignal): Promise<BuildArtifact> {
    signal?.throwIfAborted();
    const settings =
      this.#buildSettings ??
      (await resolveBuildSettings({
        appPath: this.#appPath,
        buildType: "nextjs",
        signal,
      }));
    await runBuildSettingsCommand({
      appPath: this.#appPath,
      settings,
      failurePrefix: "Next.js",
      defaultCli: {
        source: "Next.js default",
        cliName: "next",
        args: ["build"],
        missingMessage:
          "Could not find the Next.js CLI. Install it with `npm install next` or ensure npx/bunx is available.",
      },
      io: this.#io,
      signal,
    });

    const standaloneDir = path.join(this.#appPath, settings.outputDirectory);
    if (!(await directoryExists(standaloneDir))) {
      if (this.#requireStandalone || settings.entrypoint) {
        throw new Error(
          `Next.js build did not produce standalone output at ${settings.outputDirectory}. ` +
            'Set output: "standalone" in your next.config; this deploy target requires it.',
        );
      }
      // No `output: "standalone"` in next.config: the build succeeded, so
      // package the full tree and serve with `next start`. Bigger artifact,
      // same running app.
      return stageFullTreeFallbackArtifact(this.#appPath, signal);
    }

    const outDir = await mkdtemp(path.join(os.tmpdir(), "compute-build-"));
    try {
      const artifactDir = path.join(outDir, "app");
      await stageStandaloneArtifact({
        standaloneDir,
        artifactDir,
        appPath: this.#appPath,
        signal,
      });
      const entrypoint = settings.entrypoint
        ? await resolveConfiguredArtifactEntrypoint({
            directory: artifactDir,
            entrypoint: settings.entrypoint,
            label: "Next.js",
            displayDirectory: settings.outputDirectory,
            missingEntrypointMessage: `Next.js standalone output did not contain configured entrypoint ${settings.outputDirectory}/${settings.entrypoint}.`,
          })
        : await findStandaloneEntrypoint(artifactDir);
      await copyStaticAssets({
        appPath: this.#appPath,
        artifactDir,
        outputRoot: nextOutputRootFromStandaloneDirectory(
          settings.outputDirectory,
        ),
        entrypoint,
        signal,
      });

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

const FULL_TREE_ENTRYPOINT = "prisma-next-start.cjs";

// Bootstrap for apps built without `output: "standalone"`. Entering through
// the CLI bin (not `next/dist/server/lib/start-server`) keeps Next in charge
// of config loading, which is the part that drifts across Next majors.
const FULL_TREE_START_SOURCE = [
  // The runtime unpacks us at the artifact root, but `next start` resolves
  // `.next` from cwd.
  "process.chdir(__dirname);",
  'process.env.NODE_ENV = "production";',
  'process.argv.push("start", "-p", process.env.PORT ?? "3000");',
  'require("next/dist/bin/next");',
  "",
].join("\n");

async function stageFullTreeFallbackArtifact(
  appPath: string,
  signal?: AbortSignal,
): Promise<BuildArtifact> {
  const outDir = await mkdtemp(path.join(os.tmpdir(), "compute-build-"));
  try {
    const artifactDir = path.join(outDir, "app");
    // node_modules ships on purpose: without standalone output the artifact
    // must carry the full runtime dependency tree for `next start`.
    signal?.throwIfAborted();
    await cp(appPath, artifactDir, {
      recursive: true,
      // Keep relative symlinks relative (node_modules/.bin/*): the default
      // rewrites them to absolute paths into the source tree, which the
      // archiver rejects as escaping the artifact root.
      verbatimSymlinks: true,
      filter: (source) => !isExcludedFromFullTree(path.basename(source)),
    });
    signal?.throwIfAborted();
    await writeFile(
      path.join(artifactDir, FULL_TREE_ENTRYPOINT),
      FULL_TREE_START_SOURCE,
    );

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

/** Excludes VCS internals and dotenv files (local secrets, superseded by the deploy env). */
function isExcludedFromFullTree(basename: string): boolean {
  return (
    basename === ".git" || basename === ".env" || basename.startsWith(".env.")
  );
}

async function copyStaticAssets(options: {
  appPath: string;
  artifactDir: string;
  outputRoot: string;
  entrypoint: string;
  signal?: AbortSignal;
}): Promise<void> {
  const serverSubpath = serverSubpathOf(options.entrypoint);
  const serverDir = serverSubpath
    ? path.join(options.artifactDir, serverSubpath)
    : options.artifactDir;

  const publicDir = path.join(options.appPath, "public");
  if (await directoryExists(publicDir)) {
    options.signal?.throwIfAborted();
    await cp(publicDir, path.join(serverDir, "public"), {
      recursive: true,
      verbatimSymlinks: true,
    });
  }

  const staticDir = path.join(options.appPath, options.outputRoot, "static");
  if (await directoryExists(staticDir)) {
    options.signal?.throwIfAborted();
    await cp(staticDir, path.join(serverDir, options.outputRoot, "static"), {
      recursive: true,
      verbatimSymlinks: true,
    });
  }
}

/**
 * Locates `server.js` in the staged standalone output. In monorepos Next
 * preserves the workspace-relative path (e.g. `apps/web/server.js`), so the
 * entrypoint is the shallowest match. `node_modules` is skipped because
 * third-party packages may ship their own `server.js`.
 */
async function findStandaloneEntrypoint(artifactDir: string): Promise<string> {
  const rootStat = await stat(path.join(artifactDir, "server.js")).catch(
    () => null,
  );
  if (rootStat?.isFile()) {
    return "server.js";
  }

  const candidates: string[] = [];
  await walk(artifactDir);
  candidates.sort(
    (left, right) =>
      left.split("/").length - right.split("/").length ||
      left.localeCompare(right),
  );

  const selected = candidates[0];
  if (!selected) {
    throw new Error(
      `Next.js standalone output did not contain server.js in ${artifactDir}`,
    );
  }
  return selected;

  async function walk(directory: string): Promise<void> {
    const entries = await readdir(directory, { withFileTypes: true });
    for (const entry of entries) {
      if (entry.name === "node_modules") {
        continue;
      }
      const fullPath = path.join(directory, entry.name);
      if (entry.isDirectory()) {
        await walk(fullPath);
      } else if (entry.isFile() && entry.name === "server.js") {
        candidates.push(
          path.relative(artifactDir, fullPath).split(path.sep).join("/"),
        );
      }
    }
  }
}

function serverSubpathOf(entrypoint: string): string {
  const dir = path.posix.dirname(entrypoint);
  return dir === "." ? "" : dir;
}

async function directoryExists(dirPath: string): Promise<boolean> {
  const s = await stat(dirPath).catch(() => null);
  return s?.isDirectory() ?? false;
}
