/**
 * Detects how an application's database schema should be applied: which
 * engine owns it (Prisma ORM vs Prisma Next) and which CLI subcommand
 * brings the database in line with it (`migrate deploy`, `db push`, or the
 * Prisma Next init flow).
 *
 * Both deploy front doors share this so a git-push deploy and
 * `prisma app deploy` classify a repository identically.
 */
import type { Dirent } from "node:fs";
import { access, readdir, readFile, stat } from "node:fs/promises";
import path from "node:path";

export type SchemaEngine = "prisma-orm" | "prisma-next";

export type MigrationCommand =
  | "migrate-deploy"
  | "db-push"
  | "prisma-next-init";

export type UnsupportedSchemaTarget =
  | "mongodb"
  | "mysql"
  | "sqlite"
  | "sqlserver"
  | "cockroachdb";

export interface DetectedSchema {
  engine: SchemaEngine;
  /** Absolute path to `schema.prisma` (Prisma ORM) or the Prisma Next config. */
  path: string;
  command: MigrationCommand;
  /** Prisma ORM only; always `false` for Prisma Next. */
  hasMigrations: boolean;
  target: "postgresql" | "unknown";
}

export interface UnsupportedSchema {
  engine: SchemaEngine;
  path: string;
  target: UnsupportedSchemaTarget;
}

export interface SchemaDetection {
  /** A schema whose migrations can be applied, or `null` if none was found. */
  schema: DetectedSchema | null;
  /** A schema targeting an engine the migration flow does not support. */
  unsupported: UnsupportedSchema | null;
}

const SKIPPED_DIRECTORIES = new Set([
  ".git",
  ".next",
  ".nuxt",
  ".output",
  ".prisma",
  ".turbo",
  ".vercel",
  ".wrangler",
  "build",
  "coverage",
  "dist",
  "node_modules",
  "out",
]);
const MAX_SCAN_DEPTH = 6;
const MAX_SCAN_FILES = 1_000;
const MAX_TEXT_FILE_BYTES = 1024 * 1024;

/**
 * Walks `appPath` for a Prisma ORM schema or Prisma Next config and returns
 * the migration command that should run against it. Returns a `null` schema
 * when none is found, and reports a non-Postgres schema under `unsupported`
 * so callers can decide how to surface it.
 */
export async function detectAppSchema(
  appPath: string,
  signal?: AbortSignal,
): Promise<SchemaDetection> {
  const state: ScanState = {
    filesVisited: 0,
    schemaCandidates: [],
    prismaNextConfigCandidates: [],
  };

  await scanDirectory(appPath, 0, state, signal);

  const prismaNextConfigs = await Promise.all(
    state.prismaNextConfigCandidates.map((configPath) =>
      classifyPrismaNextConfig(configPath, signal),
    ),
  );
  const supportedPrismaNextConfig = selectPrismaNextConfig(
    appPath,
    prismaNextConfigs,
    "supported",
  );
  const unsupportedPrismaNextConfig = selectPrismaNextConfig(
    appPath,
    prismaNextConfigs,
    "unsupported",
  );
  const selectedPrismaOrmSchema = await selectPrismaOrmSchema(
    appPath,
    state.schemaCandidates,
    signal,
  );

  const schema: DetectedSchema | null = supportedPrismaNextConfig
    ? {
        engine: "prisma-next",
        path: supportedPrismaNextConfig.path,
        hasMigrations: false,
        command: "prisma-next-init",
        target: supportedPrismaNextConfig.target,
      }
    : selectedPrismaOrmSchema.schema;
  const unsupported: UnsupportedSchema | null = schema
    ? null
    : unsupportedPrismaNextConfig
      ? {
          engine: "prisma-next",
          path: unsupportedPrismaNextConfig.path,
          target: unsupportedPrismaNextConfig.target,
        }
      : selectedPrismaOrmSchema.unsupported;

  return { schema, unsupported };
}

interface ScanState {
  filesVisited: number;
  schemaCandidates: string[];
  prismaNextConfigCandidates: string[];
}

interface ClassifiedPrismaNextConfig {
  path: string;
  target: "postgresql" | "unknown" | UnsupportedSchemaTarget;
}

interface PrismaOrmSchemaSelection {
  schema: DetectedSchema | null;
  unsupported: UnsupportedSchema | null;
}

async function scanDirectory(
  directory: string,
  depth: number,
  state: ScanState,
  signal?: AbortSignal,
): Promise<void> {
  signal?.throwIfAborted();

  if (depth > MAX_SCAN_DEPTH || state.filesVisited >= MAX_SCAN_FILES) {
    return;
  }

  let entries: Dirent[];
  try {
    entries = await readdir(directory, { withFileTypes: true });
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code === "ENOENT") {
      return;
    }
    throw error;
  }
  entries.sort((left, right) => left.name.localeCompare(right.name));

  for (const entry of entries) {
    signal?.throwIfAborted();
    if (state.filesVisited >= MAX_SCAN_FILES) {
      return;
    }

    const entryPath = path.join(directory, entry.name);
    if (entry.isDirectory()) {
      if (!SKIPPED_DIRECTORIES.has(entry.name)) {
        await scanDirectory(entryPath, depth + 1, state, signal);
      }
      continue;
    }

    if (!entry.isFile()) {
      continue;
    }

    state.filesVisited += 1;

    if (entry.name === "schema.prisma") {
      state.schemaCandidates.push(entryPath);
    }

    if (isPrismaNextConfigFile(entry.name)) {
      state.prismaNextConfigCandidates.push(entryPath);
    }
  }
}

async function selectPrismaOrmSchema(
  appPath: string,
  candidates: string[],
  signal?: AbortSignal,
): Promise<PrismaOrmSchemaSelection> {
  const sorted = sortByPreferredRelativePath(
    appPath,
    candidates,
    "schema.prisma",
  );

  for (const schemaPath of sorted) {
    const target = await classifyPrismaOrmSchemaTarget(schemaPath, signal);
    if (target === "postgresql" || target === "unknown") {
      const hasMigrations = await hasMigrationsDirectory(
        path.dirname(schemaPath),
        signal,
      );
      return {
        schema: {
          engine: "prisma-orm",
          path: schemaPath,
          hasMigrations,
          command: hasMigrations ? "migrate-deploy" : "db-push",
          target,
        },
        unsupported: null,
      };
    }

    return {
      schema: null,
      unsupported: { engine: "prisma-orm", path: schemaPath, target },
    };
  }

  return { schema: null, unsupported: null };
}

function selectPrismaNextConfig(
  appPath: string,
  candidates: ClassifiedPrismaNextConfig[],
  mode: "supported",
): { path: string; target: "postgresql" | "unknown" } | null;
function selectPrismaNextConfig(
  appPath: string,
  candidates: ClassifiedPrismaNextConfig[],
  mode: "unsupported",
): { path: string; target: UnsupportedSchemaTarget } | null;
function selectPrismaNextConfig(
  appPath: string,
  candidates: ClassifiedPrismaNextConfig[],
  mode: "supported" | "unsupported",
): ClassifiedPrismaNextConfig | null {
  const matches = candidates.filter((candidate) => {
    const isSupported =
      candidate.target === "postgresql" || candidate.target === "unknown";
    return mode === "supported" ? isSupported : !isSupported;
  });

  return (
    sortByPreferredRelativePath(
      appPath,
      matches.map((candidate) => candidate.path),
      // prisma.config.ts is the canonical name for Prisma 8+; prefer it when
      // both it and the legacy prisma-next.config.ts are present in one app.
      "prisma.config.ts",
    )
      .map((candidatePath) =>
        matches.find((candidate) => candidate.path === candidatePath),
      )
      .find((candidate): candidate is ClassifiedPrismaNextConfig =>
        Boolean(candidate),
      ) ?? null
  );
}

function sortByPreferredRelativePath(
  appPath: string,
  candidates: string[],
  preferredRootFile: string,
): string[] {
  return candidates
    .map((candidate) => ({
      absolute: candidate,
      relative: path.relative(appPath, candidate) || preferredRootFile,
    }))
    .sort((left, right) => {
      if (left.relative === preferredRootFile) return -1;
      if (right.relative === preferredRootFile) return 1;
      return (
        left.relative.length - right.relative.length ||
        left.relative.localeCompare(right.relative)
      );
    })
    .map((candidate) => candidate.absolute);
}

async function hasMigrationsDirectory(
  schemaDirectory: string,
  signal?: AbortSignal,
): Promise<boolean> {
  signal?.throwIfAborted();
  const migrationsPath = path.join(schemaDirectory, "migrations");

  try {
    await access(migrationsPath);
    const entries = await readdir(migrationsPath);
    return entries.length > 0;
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code === "ENOENT") {
      return false;
    }
    throw error;
  }
}

async function classifyPrismaNextConfig(
  configPath: string,
  signal?: AbortSignal,
): Promise<ClassifiedPrismaNextConfig> {
  const content = await readTextFileIfSmall(configPath, signal);
  if (!content) {
    return { path: configPath, target: "unknown" };
  }

  if (content.includes("@prisma-next/postgres/config")) {
    return { path: configPath, target: "postgresql" };
  }
  if (content.includes("@prisma-next/mongo/config")) {
    return { path: configPath, target: "mongodb" };
  }
  if (content.includes("@prisma-next/sqlite/config")) {
    return { path: configPath, target: "sqlite" };
  }

  return { path: configPath, target: "unknown" };
}

async function classifyPrismaOrmSchemaTarget(
  schemaPath: string,
  signal?: AbortSignal,
): Promise<"postgresql" | "unknown" | UnsupportedSchemaTarget> {
  const content = await readTextFileIfSmall(schemaPath, signal);
  const provider = content?.match(/\bprovider\s*=\s*"([^"]+)"/)?.[1] ?? null;

  switch (provider) {
    case "postgresql":
      return "postgresql";
    case "mongodb":
      return "mongodb";
    case "mysql":
      return "mysql";
    case "sqlite":
      return "sqlite";
    case "sqlserver":
      return "sqlserver";
    case "cockroachdb":
      return "cockroachdb";
    default:
      return "unknown";
  }
}

const PRISMA_NEXT_CONFIG_EXTENSIONS = [
  ".cjs",
  ".cts",
  ".js",
  ".mjs",
  ".mts",
  ".ts",
] as const;

/**
 * Returns true when the file name is a Prisma Next config file.
 *
 * Two canonical names are accepted:
 * - `prisma.config.*` — the canonical name used by the `prisma` CLI (Prisma 8+).
 * - `prisma-next.config.*` — the legacy name used before the prisma CLI adopted
 *   the canonical name. Kept for the transition period.
 */
function isPrismaNextConfigFile(fileName: string): boolean {
  const hasRecognizedExtension = PRISMA_NEXT_CONFIG_EXTENSIONS.some(
    (extension) => fileName.endsWith(extension),
  );
  if (!hasRecognizedExtension) {
    return false;
  }
  return (
    fileName.startsWith("prisma.config.") ||
    fileName.startsWith("prisma-next.config.")
  );
}

async function readTextFileIfSmall(
  filePath: string,
  signal?: AbortSignal,
): Promise<string | null> {
  signal?.throwIfAborted();

  const info = await stat(filePath);
  if (info.size > MAX_TEXT_FILE_BYTES) {
    return null;
  }

  return readFile(filePath, { encoding: "utf8", signal });
}
