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

import { normalizeArtifactSymlinks } from "./artifact-postprocess.ts";
import type { BuildArtifact } from "./build-strategy.ts";
import {
  defaultHttpPortForBuildType,
  type FrameworkBuildType,
} from "./config/frameworks.ts";
import { stageConfiguredArtifactDependencies } from "./configured-artifact-trace.ts";

export async function stageConfiguredArtifact(options: {
  appPath: string;
  outputDirectory: string;
  entrypoint: string;
  buildType: FrameworkBuildType;
  label: string;
  missingEntrypointMessage?: string;
  traceDependencies?: boolean;
  signal?: AbortSignal;
}): Promise<BuildArtifact> {
  options.signal?.throwIfAborted();
  const outputDir = resolvePathInside(
    options.appPath,
    options.outputDirectory,
    `${options.label} build output directory must be a relative path inside the app directory`,
  );
  const outputStat = await stat(outputDir).catch(() => null);
  if (!outputStat?.isDirectory()) {
    throw new Error(
      `${options.label} build output directory does not exist: ${options.outputDirectory}`,
    );
  }

  const [realAppPath, realOutputDir] = await Promise.all([
    realpath(path.resolve(options.appPath)),
    realpath(outputDir),
  ]);
  if (!isPathWithin(realAppPath, realOutputDir)) {
    throw new Error(
      `${options.label} build output directory must stay inside the app directory`,
    );
  }

  const entrypoint = await resolveConfiguredArtifactEntrypoint({
    directory: realOutputDir,
    entrypoint: options.entrypoint,
    label: options.label,
    displayDirectory: options.outputDirectory,
    missingEntrypointMessage: options.missingEntrypointMessage,
  });

  const outDir = await mkdtemp(path.join(os.tmpdir(), "compute-build-"));
  try {
    const artifactDir = path.join(outDir, "app");
    await cp(realOutputDir, artifactDir, {
      recursive: true,
      verbatimSymlinks: true,
    });
    await normalizeArtifactSymlinks(
      artifactDir,
      options.appPath,
      options.signal,
    );
    if (options.traceDependencies) {
      await stageConfiguredArtifactDependencies({
        appPath: realAppPath,
        artifactDir,
        outputDirectory: realOutputDir,
        entrypoint,
        signal: options.signal,
      });
    }

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

export async function resolveConfiguredArtifactEntrypoint(options: {
  directory: string;
  entrypoint: string;
  label: string;
  displayDirectory?: string;
  missingEntrypointMessage?: string;
}): Promise<string> {
  const entrypoint = normalizeConfiguredArtifactEntrypoint(
    options.entrypoint,
    options.label,
  );
  const entryPath = path.join(options.directory, entrypoint);
  const entryStat = await stat(entryPath).catch(() => null);
  if (!entryStat?.isFile()) {
    if (options.missingEntrypointMessage) {
      throw new Error(options.missingEntrypointMessage);
    }
    const directory = options.displayDirectory ?? options.directory;
    throw new Error(
      `${options.label} build entrypoint does not exist: ${directory}/${entrypoint}`,
    );
  }
  return entrypoint;
}

export function normalizeConfiguredArtifactEntrypoint(
  entrypoint: string,
  label: string,
): string {
  const normalized = path.posix.normalize(
    entrypoint.trim().replace(/\\/g, "/"),
  );
  if (
    normalized.length === 0 ||
    normalized === "." ||
    normalized === ".." ||
    normalized.startsWith("../") ||
    normalized.includes("/../") ||
    path.win32.isAbsolute(entrypoint) ||
    path.posix.isAbsolute(normalized) ||
    /^[A-Za-z]:/.test(normalized)
  ) {
    throw new Error(
      `${label} build entrypoint must be a relative path inside the output directory`,
    );
  }

  return normalized;
}

function resolvePathInside(
  root: string,
  relativePath: string,
  errorMessage: string,
): string {
  const raw = relativePath.trim().replace(/\\/g, "/");
  const normalized = path.posix.normalize(raw);
  if (
    raw.length === 0 ||
    normalized === ".." ||
    normalized.startsWith("../") ||
    normalized.includes("/../") ||
    path.win32.isAbsolute(relativePath) ||
    path.posix.isAbsolute(normalized) ||
    /^[A-Za-z]:/.test(normalized)
  ) {
    throw new Error(errorMessage);
  }

  const resolved = path.resolve(root, normalized);
  const relative = path.relative(root, resolved);
  if (
    relative === ".." ||
    relative.startsWith(`..${path.sep}`) ||
    path.isAbsolute(relative)
  ) {
    throw new Error(errorMessage);
  }

  return resolved;
}

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