import { spawn } from "node:child_process";
import { readFile, stat } from "node:fs/promises";
import path from "node:path";
import type { Readable } from "node:stream";

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

/**
 * Workspace-aware build primitives shared by the framework strategies. These
 * are location-agnostic: they take an explicit app directory and walk up to
 * the source root (the repository or workspace boundary), so a build invoked
 * from inside a monorepo package resolves the same package manager and
 * binaries it would at the workspace root. Nothing here reads `process.cwd()`
 * or prompts.
 */

export type PackageManager = "bun" | "pnpm" | "yarn" | "npm";

/**
 * Build-environment hooks a consumer threads into a strategy: extra `env` for
 * the build child and a per-line output tap. Both are optional, so the CLI
 * keeps today's behavior while a headless consumer (the build-runner) injects
 * deploy vars and streams a live build log.
 */
export interface BuildCommandIo {
  env?: NodeJS.ProcessEnv;
  onOutput?: (line: string, source: "stdout" | "stderr") => void;
}

/**
 * Detects the package manager governing `appPath` by walking from the app up
 * to the source root: the nearest `packageManager` field wins, then the
 * nearest lockfile. Workspace repos keep both at the root, so a package
 * deep in a monorepo still resolves correctly. Returns undefined when no
 * signal is found.
 */
export async function resolvePackageManager(
  appPath: string,
  signal?: AbortSignal,
): Promise<PackageManager | undefined> {
  for (const directory of await sourceRootLineage(appPath, signal)) {
    const manifest = await readPackageManifest(directory, signal);
    const fromField = packageManagerFromField(manifest?.packageManager);
    if (fromField) {
      return fromField;
    }

    if (
      (await pathExists(path.join(directory, "bun.lock"), signal)) ||
      (await pathExists(path.join(directory, "bun.lockb"), signal))
    ) {
      return "bun";
    }
    if (await pathExists(path.join(directory, "pnpm-lock.yaml"), signal)) {
      return "pnpm";
    }
    if (await pathExists(path.join(directory, "yarn.lock"), signal)) {
      return "yarn";
    }
    if (await pathExists(path.join(directory, "package-lock.json"), signal)) {
      return "npm";
    }
  }

  return undefined;
}

/**
 * Build environment for `appPath`: every `node_modules/.bin` between the app
 * and its source root, prepended to PATH. Workspace repos hoist binaries like
 * `next` to the workspace root, so a build run from a package directory still
 * finds them. The returned object is suitable as the `env` for a child build
 * process.
 */
export async function buildCommandEnv(
  appPath: string,
  baseEnv: NodeJS.ProcessEnv,
  signal?: AbortSignal,
): Promise<NodeJS.ProcessEnv> {
  const binDirs = (await sourceRootLineage(appPath, signal)).map((directory) =>
    path.join(directory, "node_modules", ".bin"),
  );

  // `baseEnv` is a plain object, so Windows' case-insensitive env semantics
  // don't apply: the variable is typically cased `Path` there, and writing a
  // literal `PATH` key would shadow it with a bin-dirs-only value in the child
  // process (leaving `bun`, `npm`, etc. unresolvable). Extend the existing key
  // whatever its casing.
  const pathKey =
    Object.keys(baseEnv).find((key) => key.toUpperCase() === "PATH") ?? "PATH";

  return {
    ...baseEnv,
    [pathKey]: [...binDirs, baseEnv[pathKey]]
      .filter(Boolean)
      .join(path.delimiter),
  };
}

/**
 * Runs a build command string inside `appPath`, with every ancestor
 * `node_modules/.bin` on PATH so workspace-hoisted binaries resolve. Rejects
 * with the framework prefix and captured output on a non-zero exit.
 */
export async function runBuildCommand(options: {
  appPath: string;
  command: string;
  failurePrefix: string;
  /**
   * Extra environment merged over `process.env` for the build child (after
   * the ancestor-bin PATH). A headless consumer (e.g. the build-runner) uses
   * this to inject per-branch deploy vars and build flags; the CLI passes none.
   */
  env?: NodeJS.ProcessEnv;
  /**
   * Per-line tap for build output, called as each line arrives on stdout or
   * stderr. Lets a consumer stream a live build log instead of waiting for the
   * process to exit; the CLI passes none.
   */
  onOutput?: (line: string, source: "stdout" | "stderr") => void;
  signal?: AbortSignal;
}): Promise<void> {
  const env = await buildCommandEnv(
    options.appPath,
    { ...process.env, ...options.env },
    options.signal,
  );

  // `shell: true` preserves the platform shell so a command string like
  // `pnpm run build` works the same as the CLI's previous `exec`.
  await runChildProcess({
    command: options.command,
    args: [],
    cwd: options.appPath,
    env,
    shell: true,
    failurePrefix: options.failurePrefix,
    onOutput: options.onOutput,
    signal: options.signal,
  });
}

/**
 * Spawns a child build process, streaming each output line to `onOutput` and
 * keeping a bounded tail for the failure message instead of buffering the whole
 * build. Resolves on a zero exit; rejects on a non-zero exit (with the prefix,
 * exit code, and tail) or a spawn error (whose `code`, e.g. ENOENT, is
 * preserved on the rejection so a launcher ladder can branch on it).
 *
 * Shared by `runBuildCommand` (a shell command string) and `runPackageCli`
 * (an explicit binary + args).
 */
export function runChildProcess(options: {
  command: string;
  args: readonly string[];
  cwd: string;
  env: NodeJS.ProcessEnv;
  shell?: boolean;
  failurePrefix: string;
  onOutput?: (line: string, source: "stdout" | "stderr") => void;
  signal?: AbortSignal;
}): Promise<void> {
  return new Promise((resolve, reject) => {
    const tail = createBoundedTail();
    const child = spawn(options.command, [...options.args], {
      cwd: options.cwd,
      env: options.env,
      signal: options.signal,
      shell: options.shell ?? false,
    });

    const streamed = Promise.all([
      streamLines(child.stdout, "stdout", tail, options.onOutput),
      streamLines(child.stderr, "stderr", tail, options.onOutput),
    ]);

    child.on("error", (error) => {
      // Preserve `code` (e.g. ENOENT) so a launcher ladder can fall through.
      reject(
        Object.assign(
          new Error(`${options.failurePrefix} build failed:\n${error.message}`),
          { code: (error as NodeJS.ErrnoException).code },
        ),
      );
    });

    child.on("close", (code) => {
      streamed.then(() => {
        if (code === 0) {
          resolve();
          return;
        }
        const detail = tail.text().trim();
        const exit = code != null ? ` (exit code ${code})` : "";
        reject(
          new Error(
            `${options.failurePrefix} build failed${exit}${detail ? `:\n${detail}` : "."}`,
          ),
        );
      }, reject);
    });
  });
}

/** Splits a child stream into lines, taps each, and records it in the tail. */
function streamLines(
  stream: Readable | null,
  source: "stdout" | "stderr",
  tail: BoundedTail,
  onOutput?: (line: string, source: "stdout" | "stderr") => void,
): Promise<void> {
  return new Promise((resolve) => {
    if (!stream) {
      resolve();
      return;
    }
    stream.setEncoding("utf8");
    let buffer = "";
    const emit = (line: string): void => {
      tail.push(line);
      onOutput?.(line, source);
    };
    stream.on("data", (chunk: string) => {
      buffer += chunk;
      let newline = buffer.indexOf("\n");
      while (newline !== -1) {
        emit(buffer.slice(0, newline));
        buffer = buffer.slice(newline + 1);
        newline = buffer.indexOf("\n");
      }
    });
    stream.on("end", () => {
      if (buffer.length > 0) {
        emit(buffer);
      }
      resolve();
    });
    // A stream error surfaces through the child's own error/close handlers.
    stream.on("error", () => resolve());
  });
}

interface BoundedTail {
  push(line: string): void;
  text(): string;
}

/** A ring of the most recent output lines, for the failure message. */
function createBoundedTail(maxLines = 200): BoundedTail {
  const lines: string[] = [];
  return {
    push(line: string): void {
      lines.push(line);
      if (lines.length > maxLines) {
        lines.shift();
      }
    },
    text(): string {
      return lines.join("\n");
    },
  };
}

/** A `package.json`'s build-relevant fields. */
export interface PackageManifest {
  packageManager?: unknown;
  scripts?: Record<string, unknown>;
  main?: unknown;
  module?: unknown;
}

/** Reads and parses `appPath/package.json`, returning null on any failure. */
export async function readPackageManifest(
  appPath: string,
  signal?: AbortSignal,
): Promise<PackageManifest | null> {
  signal?.throwIfAborted();
  try {
    const content = await readFile(path.join(appPath, "package.json"), {
      encoding: "utf-8",
      signal,
    });
    const parsed = JSON.parse(content) as unknown;
    return isRecord(parsed) ? (parsed as PackageManifest) : null;
  } catch (error) {
    // A missing file or invalid JSON is expected; an abort must propagate.
    if (signal?.aborted) throw error;
    return null;
  }
}

/** The trimmed `scripts.build` string, or null when absent or empty. */
export function readBuildScript(
  manifest: PackageManifest | null,
): string | null {
  const build = manifest?.scripts?.build;
  if (typeof build !== "string") {
    return null;
  }
  const trimmed = build.trim();
  return trimmed.length > 0 ? trimmed : null;
}

function packageManagerFromField(value: unknown): PackageManager | null {
  if (typeof value !== "string") {
    return null;
  }
  const name = value.split("@")[0];
  return name === "bun" || name === "pnpm" || name === "yarn" || name === "npm"
    ? name
    : null;
}

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

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