/**
 * Applies an application's database schema against a database, owning the
 * prisma CLI resolution so every deploy front door (git-push build-runner,
 * `prisma app deploy`) runs migrations the same way.
 *
 * The resolver locates the project's own prisma binary by walking
 * `node_modules` from the schema directory up to the source root, the way
 * Node resolves a dependency. That covers npm, pnpm, yarn, and monorepo
 * layouts where prisma is installed but the flat `node_modules/.bin/prisma`
 * symlink is absent at the app root.
 */
import { spawn } from "node:child_process";
import { readFile } from "node:fs/promises";
import path from "node:path";
import type { Readable } from "node:stream";
import { sourceRootLineage } from "./config/source-root.ts";
import type {
  DetectedSchema,
  MigrationCommand,
  SchemaEngine,
} from "./detect-schema.ts";
import { detectAppSchema } from "./detect-schema.ts";
import { BuildError } from "./errors.ts";

export interface ApplyMigrationsOptions {
  /** Application root the build runs in; also the CLI working directory. */
  appPath: string;
  /** Connection used for migrations: the direct/admin database URL. */
  databaseUrl: string;
  /** Auto-detected from `appPath` when omitted. */
  schema?: DetectedSchema;
  /** Extra environment for the CLI child, e.g. a user-set `DIRECT_URL`. */
  env?: Record<string, string>;
  signal?: AbortSignal;
  onLog?: (line: string, stream: "stdout" | "stderr") => void;
}

export type ApplyMigrationsResult =
  | { applied: true; via: MigrationCommand }
  | { applied: false; reason: "no-schema" };

/**
 * Last resort for repos that ship a schema with no prisma installed at all.
 * Pinned to the 6.x line: Prisma 7 rejects the classic `url = env(...)`
 * datasource form (P1012), which is exactly the schema shape such repos have.
 * Bump deliberately, never to `latest`.
 */
export const FALLBACK_PRISMA_CLI_VERSION = "6.19.3";

export interface CliLauncher {
  command: string;
  argsPrefix: string[];
  /** Human-readable command prefix used in failure messages. */
  display: string;
}

/**
 * Applies the app's schema and returns how it was applied, or `no-schema`
 * when the app has none. Throws {@link BuildError} on failure: a non-zero
 * CLI exit carries `exited with code N` (a user-fixable error), while an
 * unrunnable launcher does not (an infrastructure error).
 */
export async function applyMigrations(
  opts: ApplyMigrationsOptions,
): Promise<ApplyMigrationsResult> {
  opts.signal?.throwIfAborted();

  const schema =
    opts.schema ?? (await detectAppSchema(opts.appPath, opts.signal)).schema;
  if (!schema) {
    return { applied: false, reason: "no-schema" };
  }

  const launchers = await resolveSchemaEngineCli(
    schema,
    opts.appPath,
    opts.signal,
  );
  const relativeSchemaPath =
    path.relative(opts.appPath, schema.path) ||
    defaultSchemaSourcePath(schema.engine);
  const env = {
    ...process.env,
    ...opts.env,
    DATABASE_URL: opts.databaseUrl,
    // Update nags pollute the migration log and failure snippets.
    PRISMA_HIDE_UPDATE_MESSAGE: "1",
  };

  for (const subcommand of subcommandsFor(
    schema,
    relativeSchemaPath,
    opts.databaseUrl,
  )) {
    await runWithLaunchers({
      launchers,
      subcommand: subcommand.args,
      subcommandDisplay: subcommand.display,
      cwd: opts.appPath,
      env,
      signal: opts.signal,
      onLog: opts.onLog,
    });
  }

  return { applied: true, via: schema.command };
}

/**
 * Resolves the launcher ladder for a schema's engine: the project's own
 * binary first (run via `node`), then download fallbacks tried in order when
 * the binary is not installed.
 */
export async function resolveSchemaEngineCli(
  schema: DetectedSchema,
  appPath: string,
  signal?: AbortSignal,
): Promise<CliLauncher[]> {
  const startDir = path.dirname(schema.path) || appPath;

  if (schema.engine === "prisma-next") {
    const localBin = await resolveLocalCliBin(
      startDir,
      "@prisma-next/cli",
      "prisma-next",
      signal,
    );
    if (localBin) {
      return [
        { command: "node", argsPrefix: [localBin], display: "prisma-next" },
      ];
    }
    // `@prisma-next/cli` is not published to npm, so there is no download
    // fallback; `--no-install` keeps today's behaviour when it is missing.
    return [
      {
        command: "npx",
        argsPrefix: ["--no-install", "prisma-next"],
        display: "npx --no-install prisma-next",
      },
    ];
  }

  const localBin = await resolveLocalCliBin(
    startDir,
    "prisma",
    "prisma",
    signal,
  );
  if (localBin) {
    return [{ command: "node", argsPrefix: [localBin], display: "prisma" }];
  }

  const pinned =
    (await readResolvedPrismaVersion(startDir, signal)) ??
    FALLBACK_PRISMA_CLI_VERSION;
  return [
    {
      command: "npx",
      argsPrefix: ["--yes", "--package", `prisma@${pinned}`, "prisma"],
      display: `npx prisma@${pinned}`,
    },
    {
      command: "bunx",
      argsPrefix: [`prisma@${pinned}`],
      display: `bunx prisma@${pinned}`,
    },
  ];
}

async function resolveLocalCliBin(
  startDir: string,
  packageName: string,
  binName: string,
  signal?: AbortSignal,
): Promise<string | null> {
  for (const dir of await sourceRootLineage(startDir, signal)) {
    const packageDir = path.join(dir, "node_modules", packageName);
    const bin = await readBinFromPackageJson(
      path.join(packageDir, "package.json"),
      binName,
      signal,
    );
    if (bin) {
      return path.resolve(packageDir, bin);
    }
  }
  return null;
}

async function readBinFromPackageJson(
  packageJsonPath: string,
  binName: string,
  signal?: AbortSignal,
): Promise<string | null> {
  let raw: string;
  try {
    raw = await readFile(packageJsonPath, { encoding: "utf8", signal });
  } catch (error) {
    if (signal?.aborted) throw error;
    return null;
  }

  let parsed: unknown;
  try {
    parsed = JSON.parse(raw);
  } catch {
    return null;
  }

  const bin = (parsed as { bin?: unknown }).bin;
  if (typeof bin === "string") {
    return bin;
  }
  if (bin && typeof bin === "object") {
    const entry = (bin as Record<string, unknown>)[binName];
    return typeof entry === "string" ? entry : null;
  }
  return null;
}

async function readResolvedPrismaVersion(
  startDir: string,
  signal?: AbortSignal,
): Promise<string | null> {
  for (const dir of await sourceRootLineage(startDir, signal)) {
    const version = await readPackageVersion(
      path.join(dir, "node_modules", "@prisma", "client", "package.json"),
      signal,
    );
    if (version) {
      return version;
    }
  }
  return null;
}

async function readPackageVersion(
  packageJsonPath: string,
  signal?: AbortSignal,
): Promise<string | null> {
  let raw: string;
  try {
    raw = await readFile(packageJsonPath, { encoding: "utf8", signal });
  } catch (error) {
    if (signal?.aborted) throw error;
    return null;
  }

  try {
    const parsed: unknown = JSON.parse(raw);
    const version = (parsed as { version?: unknown }).version;
    return typeof version === "string" && version.length > 0 ? version : null;
  } catch {
    return null;
  }
}

function subcommandsFor(
  schema: DetectedSchema,
  relativeSchemaPath: string,
  databaseUrl: string,
): Array<{ args: string[]; display: string }> {
  switch (schema.command) {
    case "migrate-deploy":
      return [
        {
          args: ["migrate", "deploy", "--schema", relativeSchemaPath],
          display: "migrate deploy",
        },
      ];
    case "db-push":
      return [
        {
          args: ["db", "push", "--schema", relativeSchemaPath],
          display: "db push",
        },
      ];
    case "prisma-next-init":
      return [
        {
          args: ["contract", "emit", "--config", relativeSchemaPath],
          display: "contract emit",
        },
        {
          args: [
            "db",
            "init",
            "--config",
            relativeSchemaPath,
            "--db",
            databaseUrl,
          ],
          display: "db init",
        },
      ];
    default: {
      const exhaustive: never = schema.command;
      throw new BuildError({
        message: `Unknown migration command: ${String(exhaustive)}`,
      });
    }
  }
}

function defaultSchemaSourcePath(engine: SchemaEngine): string {
  return engine === "prisma-next" ? "prisma-next.config.ts" : "schema.prisma";
}

type LauncherOutcome =
  | { kind: "ok" }
  | { kind: "unavailable" }
  | { kind: "failed"; code: number };

/**
 * Runs `subcommand` against the launcher ladder, falling through to the next
 * launcher when one cannot run (spawn ENOENT or exit 127) and throwing on the
 * first launcher that runs the tool to a real non-zero exit.
 */
export async function runWithLaunchers(opts: {
  launchers: CliLauncher[];
  subcommand: string[];
  subcommandDisplay: string;
  cwd: string;
  env: NodeJS.ProcessEnv;
  signal?: AbortSignal;
  onLog?: (line: string, stream: "stdout" | "stderr") => void;
}): Promise<void> {
  for (const launcher of opts.launchers) {
    opts.signal?.throwIfAborted();
    const outcome = await runOnce(launcher, opts);
    if (outcome.kind === "ok") {
      return;
    }
    if (outcome.kind === "failed") {
      throw new BuildError({
        message: `${launcher.display} ${opts.subcommandDisplay} exited with code ${outcome.code}`,
      });
    }
  }

  throw new BuildError({
    message: `Could not run ${opts.subcommandDisplay}: no prisma launcher was runnable (tried ${opts.launchers
      .map((launcher) => launcher.display)
      .join(", ")})`,
  });
}

function runOnce(
  launcher: CliLauncher,
  opts: {
    subcommand: string[];
    cwd: string;
    env: NodeJS.ProcessEnv;
    signal?: AbortSignal;
    onLog?: (line: string, stream: "stdout" | "stderr") => void;
  },
): Promise<LauncherOutcome> {
  return new Promise<LauncherOutcome>((resolve, reject) => {
    const child = spawn(
      launcher.command,
      [...launcher.argsPrefix, ...opts.subcommand],
      {
        cwd: opts.cwd,
        env: opts.env,
        signal: opts.signal,
        stdio: ["ignore", "pipe", "pipe"],
      },
    );

    const flushStdout = streamLines(child.stdout, "stdout", opts.onLog);
    const flushStderr = streamLines(child.stderr, "stderr", opts.onLog);

    child.once("error", (error: NodeJS.ErrnoException) => {
      if (opts.signal?.aborted) {
        reject(error);
        return;
      }
      // A launcher that is not installed (e.g. `bunx` absent) should fall
      // through to the next, not surface as a migration failure.
      if (error.code === "ENOENT") {
        resolve({ kind: "unavailable" });
        return;
      }
      reject(
        new BuildError({
          message: `Failed to start ${launcher.display}: ${error.message}`,
        }),
      );
    });

    child.once("close", (code, terminationSignal) => {
      flushStdout();
      flushStderr();

      if (terminationSignal) {
        if (opts.signal?.aborted) {
          reject(opts.signal.reason ?? new Error("aborted"));
          return;
        }
        reject(
          new BuildError({
            message: `${launcher.display} was terminated by ${terminationSignal}`,
          }),
        );
        return;
      }

      if (code === 0) {
        resolve({ kind: "ok" });
        return;
      }
      // Exit 127 is the shell's "command not found": the launcher could not
      // exec the tool. Treat it like ENOENT and try the next launcher rather
      // than blaming the user's schema.
      if (code === 127) {
        resolve({ kind: "unavailable" });
        return;
      }
      resolve({ kind: "failed", code: code ?? 1 });
    });
  });
}

function streamLines(
  stream: Readable | null,
  channel: "stdout" | "stderr",
  onLog?: (line: string, stream: "stdout" | "stderr") => void,
): () => void {
  if (!stream) {
    return () => {};
  }

  let buffer = "";
  stream.on("data", (chunk: Buffer) => {
    buffer += chunk.toString("utf8");
    let newline = buffer.indexOf("\n");
    while (newline !== -1) {
      const line = buffer.slice(0, newline);
      if (line.length > 0) {
        onLog?.(line, channel);
      }
      buffer = buffer.slice(newline + 1);
      newline = buffer.indexOf("\n");
    }
  });

  return () => {
    if (buffer.length > 0) {
      onLog?.(buffer, channel);
      buffer = "";
    }
  };
}
