import { readdir, readFile, stat } from "node:fs/promises";
import path from "node:path";
import type { BuildSettings } from "./build-settings.ts";
import type { PortMapping } from "./types.ts";
import {
  type BuildCommandIo,
  buildCommandEnv,
  runBuildCommand,
  runChildProcess,
} from "./workspace.ts";

/**
 * The result of executing a build strategy.
 */
export interface BuildArtifact {
  /** Absolute path to the directory containing the built files. */
  directory: string;

  /** Entrypoint file path, relative to `directory`, using posix separators. */
  entrypoint: string;

  /**
   * Default port mapping for the application. Used as the port mapping
   * when deploying to a newly created service and the user did not provide
   * an explicit --http-port.
   */
  defaultPortMapping?: PortMapping;

  /**
   * Optional cleanup callback. Called by the core after the archive has been
   * created and the artifact directory is no longer needed. Strategies that
   * create temporary output directories (e.g., BunBuild) use this to clean up.
   */
  cleanup?(): Promise<void>;
}

/**
 * A build strategy produces a deployable artifact.
 */
export interface BuildStrategy {
  canBuild(signal?: AbortSignal): Promise<boolean>;
  execute(signal?: AbortSignal): Promise<BuildArtifact>;
}

/**
 * Reads directly from a pre-built application directory without copying.
 * Validates the entrypoint exists.
 */
export class PreBuilt implements BuildStrategy {
  readonly #appPath: string;
  readonly #entrypoint: string;

  constructor(options: { appPath: string; entrypoint: string }) {
    this.#appPath = options.appPath;
    this.#entrypoint = options.entrypoint;
  }

  async canBuild(_signal?: AbortSignal): Promise<boolean> {
    return true;
  }

  async execute(signal?: AbortSignal): Promise<BuildArtifact> {
    signal?.throwIfAborted();
    const normalized = path.normalize(this.#entrypoint);

    if (path.isAbsolute(normalized)) {
      throw new Error("Entrypoint must be a relative path");
    }

    if (normalized.startsWith("..")) {
      throw new Error("Entrypoint must not escape the application directory");
    }

    const fullPath = path.join(this.#appPath, normalized);
    const stats = await stat(fullPath).catch(() => null);
    if (!stats?.isFile()) {
      throw new Error(
        `Entrypoint not found in pre-built artifact: ${this.#entrypoint}`,
      );
    }

    return {
      directory: this.#appPath,
      entrypoint: normalized.split(path.sep).join("/"),
    };
  }
}

// ---------------------------------------------------------------------------
// Shared helpers used by framework strategies (Next, Nuxt, Astro, TanStack
// Start). Live here because they're tightly tied to the BuildStrategy surface
// (framework detection + CLI dispatch) and don't justify their own module.
// ---------------------------------------------------------------------------

/**
 * True if any of `filenames` exists at the root of `appPath`. Swallows
 * readdir errors (missing/unreadable dir) and returns false.
 */
export async function hasRootFile(
  appPath: string,
  filenames: readonly string[],
  signal?: AbortSignal,
): Promise<boolean> {
  signal?.throwIfAborted();
  let entries: string[];
  try {
    entries = await readdir(appPath);
  } catch {
    return false;
  }
  return entries.some((entry) => filenames.includes(entry));
}

/**
 * True if any of `packageNames` is listed in `package.json`'s `dependencies`
 * or `devDependencies`. Swallows missing-file / invalid-JSON errors and
 * returns false.
 */
export async function hasPackageDependency(
  appPath: string,
  packageNames: readonly string[],
  signal?: AbortSignal,
): Promise<boolean> {
  signal?.throwIfAborted();
  let content: string;
  try {
    content = await readFile(path.join(appPath, "package.json"), "utf-8");
  } catch {
    return false;
  }

  let parsed: { dependencies?: unknown; devDependencies?: unknown };
  try {
    parsed = JSON.parse(content) as {
      dependencies?: unknown;
      devDependencies?: unknown;
    };
  } catch {
    return false;
  }

  const deps = isRecord(parsed.dependencies) ? parsed.dependencies : {};
  const devDeps = isRecord(parsed.devDependencies)
    ? parsed.devDependencies
    : {};

  return packageNames.some((name) => name in deps || name in devDeps);
}

/**
 * Run a package's CLI inside `appPath`. Tries the project-local bin first,
 * then `npx <cliName>`, then `bunx <cliName>`. Each candidate that fails
 * with ENOENT is skipped; other failures (e.g., build errors) re-throw
 * with stderr surfaced.
 *
 * Throws with `missingMessage` if none of the launchers is available.
 */
export async function runPackageCli(opts: {
  appPath: string;
  /** Executable name, e.g. "astro", "next", "nuxt", "vite". */
  cliName: string;
  /** Args passed to the CLI, e.g. ["build"]. */
  args: string[];
  /** Prefix used when surfacing a build failure, e.g. "Astro". */
  failurePrefix: string;
  /** Thrown when no launcher is available. */
  missingMessage: string;
  /** Extra environment merged over `process.env` for the build child. */
  env?: NodeJS.ProcessEnv;
  /** Per-line tap for build output (stdout/stderr). */
  onOutput?: (line: string, source: "stdout" | "stderr") => void;
  signal?: AbortSignal;
}): Promise<void> {
  // Every ancestor `node_modules/.bin` on PATH plus any injected deploy vars,
  // so a workspace-hoisted CLI resolves and the build sees its deploy env.
  const env = await buildCommandEnv(
    opts.appPath,
    { ...process.env, ...opts.env },
    opts.signal,
  );

  const localBin = path.join(
    opts.appPath,
    "node_modules",
    ".bin",
    opts.cliName,
  );
  const candidates: Array<{ command: string; args: string[] }> = [
    { command: localBin, args: opts.args },
    { command: "npx", args: [opts.cliName, ...opts.args] },
    { command: "bunx", args: [opts.cliName, ...opts.args] },
  ];

  for (const { command, args } of candidates) {
    opts.signal?.throwIfAborted();
    try {
      await runChildProcess({
        command,
        args,
        cwd: opts.appPath,
        env,
        failurePrefix: opts.failurePrefix,
        onOutput: opts.onOutput,
        signal: opts.signal,
      });
      return;
    } catch (error) {
      if (isENOENT(error)) continue;
      throw error;
    }
  }

  throw new Error(opts.missingMessage);
}

/**
 * Runs the command described by build settings. A configured shell command runs
 * as-is; a framework default can use the launcher ladder so local binaries,
 * npx, and bunx all work consistently. `null` means "skip build".
 */
export async function runBuildSettingsCommand(options: {
  appPath: string;
  settings: Pick<BuildSettings, "buildCommand" | "buildCommandSource">;
  failurePrefix: string;
  defaultCli?: {
    source: string;
    cliName: string;
    args: string[];
    missingMessage: string;
  };
  io?: BuildCommandIo;
  signal?: AbortSignal;
}): Promise<void> {
  if (!options.settings.buildCommand) {
    return;
  }

  if (
    options.defaultCli &&
    options.settings.buildCommandSource === options.defaultCli.source
  ) {
    await runPackageCli({
      appPath: options.appPath,
      cliName: options.defaultCli.cliName,
      args: options.defaultCli.args,
      failurePrefix: options.failurePrefix,
      missingMessage: options.defaultCli.missingMessage,
      env: options.io?.env,
      onOutput: options.io?.onOutput,
      signal: options.signal,
    });
    return;
  }

  await runBuildCommand({
    appPath: options.appPath,
    command: options.settings.buildCommand,
    failurePrefix: options.failurePrefix,
    env: options.io?.env,
    onOutput: options.io?.onOutput,
    signal: options.signal,
  });
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null;
}

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