import picomatch from "picomatch";
import { parse as parseYaml } from "yaml";

import {
  type ComputeAppManifest,
  type DetectedComputeApp,
  detectComputeApp,
} from "./detect-app.ts";
import { type FrameworkBuildType, frameworkByKey } from "./frameworks.ts";
import type { ComputeFramework } from "./types.ts";

const MAX_PACKAGE_MANIFESTS = 80;
const PACKAGE_FETCH_BATCH_SIZE = 10;
const IGNORED_PATH_SEGMENTS = new Set([
  ".git",
  ".next",
  ".output",
  "build",
  "dist",
  "node_modules",
  "vendor",
]);

/**
 * Lockfile filenames in precedence order, paired with their package manager.
 * bun.lock (text format, Bun 1.1+) is checked before bun.lockb (binary).
 */
const LOCKFILE_ENTRIES = [
  ["bun.lock", "bun"],
  ["bun.lockb", "bun"],
  ["pnpm-lock.yaml", "pnpm"],
  ["yarn.lock", "yarn"],
  ["package-lock.json", "npm"],
] as const satisfies ReadonlyArray<[string, "npm" | "pnpm" | "yarn" | "bun"]>;

interface ParsedManifest {
  path: string;
  root: string;
  value: ComputeAppManifest;
}

export interface DetectComputeRepositoryInput {
  repositoryName: string;
  filePaths: Iterable<string>;
  readFile: (path: string) => Promise<string | null>;
}

/** Package manager detected for the repository root. */
export interface DetectedPackageManager {
  /** Package manager name. */
  name: "npm" | "pnpm" | "yarn" | "bun";
  /**
   * Lockfile found at the repository root.
   * null when the package manager was resolved from the packageManager field
   * alone and no lockfile was present.
   */
  lockfile: string | null;
}

export interface DetectedComputeRepositoryApp {
  id: string;
  target: string;
  name: string;
  root: string;
  framework: ComputeFramework;
  frameworkName: string;
  httpPort: number;
  entry: string;
  build: {
    command: string | null;
    outputDirectory: string;
    entrypoint: string;
  };
  inferredFrom: string[];
  reviewRequired: boolean;
  /**
   * Prisma Next usage for this app. "Prisma" here means Prisma Next (Prisma 8+),
   * the engine-less, @prisma-next/-scoped client. Earlier Prisma clients
   * (@prisma/client, prisma-client-js) are not part of this system's vocabulary
   * and are not detected.
   */
  prismaNext: {
    /** True when a Prisma Next config file or a @prisma-next/* package was found. */
    detected: boolean;
    /** Signal that triggered detection, or empty when detected is false. */
    inferredFrom: string[];
  };
}

export interface DetectComputeRepositoryResult {
  apps: DetectedComputeRepositoryApp[];
  warnings: string[];
  /**
   * Package manager detected from the repository root's packageManager field
   * and lockfile. null when neither signal is present.
   */
  packageManager: DetectedPackageManager | null;
}

/**
 * Detects every deployable app in a remote repository snapshot, including
 * workspace discovery, root-app ownership, and inferred build settings.
 */
export async function detectComputeRepository(
  input: DetectComputeRepositoryInput,
): Promise<DetectComputeRepositoryResult> {
  const filePaths = new Set(input.filePaths);
  const warnings: string[] = [];
  const manifestPaths = [...filePaths]
    .filter(
      (path) =>
        (path === "package.json" || path.endsWith("/package.json")) &&
        !path.split("/").some((segment) => IGNORED_PATH_SEGMENTS.has(segment)),
    )
    .sort((a, b) => pathDepth(a) - pathDepth(b) || a.localeCompare(b));

  if (manifestPaths.length > MAX_PACKAGE_MANIFESTS) {
    warnings.push(
      `Only the first ${MAX_PACKAGE_MANIFESTS} package manifests were inspected.`,
    );
  }

  const manifests: ParsedManifest[] = [];
  for (
    let offset = 0;
    offset < Math.min(manifestPaths.length, MAX_PACKAGE_MANIFESTS);
    offset += PACKAGE_FETCH_BATCH_SIZE
  ) {
    const batch = manifestPaths.slice(
      offset,
      offset + PACKAGE_FETCH_BATCH_SIZE,
    );
    const fetched = await Promise.all(
      batch.map(async (path) => {
        try {
          return { path, content: await input.readFile(path) };
        } catch {
          warnings.push(`Skipped unreadable ${path}.`);
          return { path, content: undefined };
        }
      }),
    );

    for (const item of fetched) {
      if (item.content === undefined) continue;
      if (item.content === null) {
        warnings.push(`Skipped unavailable ${item.path}.`);
        continue;
      }
      try {
        const value: unknown = JSON.parse(item.content);
        if (!value || typeof value !== "object" || Array.isArray(value)) {
          warnings.push(`Skipped invalid package manifest in ${item.path}.`);
          continue;
        }
        manifests.push({
          path: item.path,
          root: directoryName(item.path),
          value: value as ComputeAppManifest,
        });
      } catch {
        warnings.push(`Skipped invalid JSON in ${item.path}.`);
      }
    }
  }

  const rootManifest = manifests.find((manifest) => manifest.root === "");
  const workspacePatterns = await readWorkspacePatterns({
    input,
    filePaths,
    rootManifest,
    warnings,
  });
  const candidateManifests = manifests.filter(
    (manifest) =>
      manifest.root === "" ||
      workspacePatterns.length === 0 ||
      matchesWorkspace(manifest.root, workspacePatterns),
  );
  const allDetected = candidateManifests
    .map((manifest) => {
      const detectedApp = detectComputeApp({
        root: manifest.root,
        manifest: manifest.value,
        filePaths,
      });
      return detectedApp ? { manifest, detectedApp } : null;
    })
    .filter((item): item is NonNullable<typeof item> => item !== null);
  const nestedDetected = allDetected.filter(
    (item) => item.manifest.root !== "",
  );
  const nestedAppRoots = nestedDetected.map((item) => item.manifest.root);
  const detected = allDetected.filter(
    (item) =>
      item.manifest.root !== "" ||
      nestedDetected.length === 0 ||
      hasDistinctRootAppSignal(
        item.manifest,
        item.detectedApp,
        filePaths,
        nestedAppRoots,
      ),
  );

  if (detected.length !== allDetected.length) {
    warnings.push(
      "The root package did not identify an application distinct from the detected workspace apps, so it was not added as a separate deploy target.",
    );
  }

  const selected =
    detected.length > 0
      ? detected
      : [
          {
            manifest: rootManifest ??
              candidateManifests[0] ?? {
                path: "package.json",
                root: "",
                value: {},
              },
            detectedApp: null,
          },
        ];

  if (detected.length === 0) {
    warnings.push(
      "No supported framework signal was found. Review the Bun entrypoint and build settings.",
    );
  }

  const apps = selected.map((item) =>
    inferApp({
      repositoryName: input.repositoryName,
      filePaths,
      rootManifest,
      manifest: item.manifest,
      detectedApp: item.detectedApp,
    }),
  );
  uniquifyTargets(apps);

  const packageManager = detectRepoPackageManager({
    rootManifest,
    filePaths,
    warnings,
  });

  return { apps, warnings, packageManager };
}

async function readWorkspacePatterns(args: {
  input: DetectComputeRepositoryInput;
  filePaths: Set<string>;
  rootManifest: ParsedManifest | undefined;
  warnings: string[];
}): Promise<string[]> {
  const fromPackageJson = workspacePatternsFromPackageJson(
    args.rootManifest?.value.workspaces,
  );
  if (!args.filePaths.has("pnpm-workspace.yaml")) {
    return fromPackageJson;
  }

  let content: string | null;
  try {
    content = await args.input.readFile("pnpm-workspace.yaml");
  } catch {
    args.warnings.push("Skipped unreadable pnpm-workspace.yaml.");
    return fromPackageJson;
  }
  if (content === null) return fromPackageJson;

  try {
    const parsed = parseYaml(content) as { packages?: unknown } | null;
    const fromPnpm = Array.isArray(parsed?.packages)
      ? parsed.packages.filter(
          (value): value is string => typeof value === "string",
        )
      : [];
    return [...new Set([...fromPackageJson, ...fromPnpm])];
  } catch {
    args.warnings.push("Skipped invalid YAML in pnpm-workspace.yaml.");
    return fromPackageJson;
  }
}

function workspacePatternsFromPackageJson(workspaces: unknown): string[] {
  if (Array.isArray(workspaces)) {
    return workspaces.filter(
      (value): value is string => typeof value === "string",
    );
  }
  if (
    workspaces &&
    typeof workspaces === "object" &&
    "packages" in workspaces &&
    Array.isArray(workspaces.packages)
  ) {
    return workspaces.packages.filter(
      (value): value is string => typeof value === "string",
    );
  }
  return [];
}

function matchesWorkspace(root: string, patterns: string[]): boolean {
  const included = patterns
    .filter((pattern) => !pattern.startsWith("!"))
    .some((pattern) => picomatch(pattern, { dot: true })(root));
  const excluded = patterns
    .filter((pattern) => pattern.startsWith("!"))
    .some((pattern) => picomatch(pattern.slice(1), { dot: true })(root));
  return included && !excluded;
}

function hasDistinctRootAppSignal(
  manifest: ParsedManifest,
  detectedApp: DetectedComputeApp,
  filePaths: Set<string>,
  nestedAppRoots: string[],
): boolean {
  const descriptor = frameworkByKey(detectedApp.framework);
  if (
    descriptor.detectConfigFiles.some((fileName) => filePaths.has(fileName))
  ) {
    return true;
  }
  const entrypoint = detectedApp.entrypoint;
  if (
    entrypoint &&
    !nestedAppRoots.some((root) => isPathInside(entrypoint, root))
  ) {
    return true;
  }

  const sourcePaths = [...filePaths].filter(
    (path) => !nestedAppRoots.some((root) => isPathInside(path, root)),
  );
  switch (detectedApp.buildType) {
    case "nextjs":
      if (
        sourcePaths.some((path) =>
          ["app/", "pages/", "src/app/", "src/pages/"].some((prefix) =>
            path.startsWith(prefix),
          ),
        )
      ) {
        return true;
      }
      break;
    case "nuxt":
      if (
        filePaths.has("app.vue") ||
        sourcePaths.some(
          (path) => path.startsWith("pages/") || path.startsWith("server/"),
        )
      ) {
        return true;
      }
      break;
    case "astro":
      if (sourcePaths.some((path) => path.startsWith("src/pages/"))) {
        return true;
      }
      break;
    case "nestjs":
      if (filePaths.has("src/main.ts") || filePaths.has("src/main.js")) {
        return true;
      }
      break;
    case "tanstack-start":
      if (
        sourcePaths.some(
          (path) =>
            path.startsWith("app/routes/") || path.startsWith("src/routes/"),
        )
      ) {
        return true;
      }
      break;
    case "custom":
    case "bun":
      break;
  }

  const buildScript = readBuildScript(manifest.value.scripts);
  if (!buildScript) return false;
  switch (detectedApp.buildType) {
    case "nextjs":
      return /(^|\s)next\s+build(?:\s|$)/.test(buildScript);
    case "nuxt":
      return /(^|\s)(?:nuxt|nuxi)\s+build(?:\s|$)/.test(buildScript);
    case "astro":
      return /(^|\s)astro\s+build(?:\s|$)/.test(buildScript);
    case "nestjs":
      return /(^|\s)nest\s+build(?:\s|$)/.test(buildScript);
    case "tanstack-start":
      return /(^|\s)vite\s+build(?:\s|$)/.test(buildScript);
    case "custom":
    case "bun":
      return false;
  }
}

function inferApp(args: {
  repositoryName: string;
  filePaths: Set<string>;
  rootManifest: ParsedManifest | undefined;
  manifest: ParsedManifest;
  detectedApp: DetectedComputeApp | null;
}): DetectedComputeRepositoryApp {
  const packageName =
    typeof args.manifest.value.name === "string"
      ? args.manifest.value.name
      : undefined;
  const target = normalizeTarget(
    packageName ?? lastPathSegment(args.manifest.root) ?? args.repositoryName,
  );
  const buildScript = readBuildScript(args.manifest.value.scripts);
  const packageManager = resolveRemotePackageManager({
    manifest: args.manifest,
    rootManifest: args.rootManifest,
    filePaths: args.filePaths,
  });
  const descriptor = frameworkByKey(args.detectedApp?.framework ?? "bun");
  const inferredBuild = inferBuildSettings({
    buildType: args.detectedApp?.buildType ?? descriptor.buildType,
    buildScript,
    packageManager,
  });
  const entry = args.detectedApp?.entrypoint ?? "";
  const detectionSource =
    args.detectedApp?.evidence.kind === "dependency"
      ? `${args.detectedApp.evidence.path} dependency ${args.detectedApp.evidence.value}`
      : (args.detectedApp?.evidence.path ?? "Bun fallback");

  return {
    id: args.manifest.root || "root",
    target,
    name: target,
    root: args.manifest.root ? `./${args.manifest.root}` : "./",
    framework: descriptor.key,
    frameworkName: descriptor.displayName,
    httpPort: descriptor.defaultHttpPort,
    entry,
    build: {
      command: inferredBuild.command,
      outputDirectory: inferredBuild.outputDirectory,
      entrypoint: inferredBuild.entrypoint,
    },
    inferredFrom: [
      detectionSource,
      buildScript
        ? `${args.manifest.path} scripts.build`
        : inferredBuild.source,
    ].filter((value, index, values) => values.indexOf(value) === index),
    reviewRequired:
      args.detectedApp === null || (descriptor.usesEntrypoint && !entry),
    prismaNext: detectAppPrismaNext({
      manifest: args.manifest.value,
      root: args.manifest.root,
      filePaths: args.filePaths,
    }),
  };
}

/**
 * Detects the package manager declared at the repository root.
 * Reads the root packageManager field and lockfiles; warns on conflict.
 */
function detectRepoPackageManager(args: {
  rootManifest: ParsedManifest | undefined;
  filePaths: Set<string>;
  warnings: string[];
}): DetectedPackageManager | null {
  const nameFromField = packageManagerFromField(
    args.rootManifest?.value.packageManager,
  );

  let lockfile: string | null = null;
  let nameFromLockfile: "npm" | "pnpm" | "yarn" | "bun" | null = null;
  for (const [file, name] of LOCKFILE_ENTRIES) {
    if (args.filePaths.has(file)) {
      lockfile = file;
      nameFromLockfile = name;
      break;
    }
  }

  if (nameFromField) {
    if (nameFromLockfile && nameFromField !== nameFromLockfile) {
      args.warnings.push(
        `The packageManager field declares "${nameFromField}" but lockfile "${lockfile}" belongs to "${nameFromLockfile}". Using the packageManager field.`,
      );
    }
    return { name: nameFromField, lockfile };
  }

  if (nameFromLockfile) {
    return { name: nameFromLockfile, lockfile };
  }

  return null;
}

/**
 * Detects Prisma Next usage for one app.
 *
 * Signals checked (in order):
 * 1. prisma.config.ts at the app root — the canonical Prisma 8+ config file.
 * 2. prisma-next.config.ts at the app root — the legacy name kept for the
 *    transition period.
 * 3. Any @prisma-next/* scoped package in dependencies or devDependencies.
 * 4. The prisma-next bare package (CLI tool) in devDependencies.
 *
 * Earlier Prisma clients (@prisma/client, prisma) are not part of this system
 * and are deliberately ignored here.
 */
function detectAppPrismaNext(args: {
  manifest: ComputeAppManifest;
  root: string;
  filePaths: Set<string>;
}): DetectedComputeRepositoryApp["prismaNext"] {
  // prisma.config.ts is the canonical config file for Prisma 8+.
  const canonicalConfigFile = joinRepoPath(args.root, "prisma.config.ts");
  if (args.filePaths.has(canonicalConfigFile)) {
    return { detected: true, inferredFrom: [canonicalConfigFile] };
  }

  // prisma-next.config.ts is the legacy name; kept for the transition period.
  const legacyConfigFile = joinRepoPath(args.root, "prisma-next.config.ts");
  if (args.filePaths.has(legacyConfigFile)) {
    return { detected: true, inferredFrom: [legacyConfigFile] };
  }

  // Any @prisma-next/* scoped package or the bare prisma-next CLI.
  const deps = asRecord(args.manifest.dependencies);
  const devDeps = asRecord(args.manifest.devDependencies);
  const manifestPath = joinRepoPath(args.root, "package.json");

  for (const name of [...Object.keys(deps), ...Object.keys(devDeps)]) {
    if (name === "prisma-next" || name.startsWith("@prisma-next/")) {
      return {
        detected: true,
        inferredFrom: [`${manifestPath} dependency ${name}`],
      };
    }
  }

  return { detected: false, inferredFrom: [] };
}

/** Coerces an unknown value to a string-keyed record, returning {} for non-objects. */
function asRecord(value: unknown): Record<string, unknown> {
  return value && typeof value === "object" && !Array.isArray(value)
    ? (value as Record<string, unknown>)
    : {};
}

function inferBuildSettings(args: {
  buildType: FrameworkBuildType;
  buildScript: string | null;
  packageManager: string | null;
}): DetectedComputeRepositoryApp["build"] & { source: string } {
  const scriptCommand = args.buildScript
    ? args.packageManager
      ? `${args.packageManager} run build`
      : args.buildScript
    : null;
  switch (args.buildType) {
    case "nextjs":
      return {
        command: scriptCommand ?? "next build",
        outputDirectory: "",
        entrypoint: "",
        source: "Next.js build strategy",
      };
    case "nuxt":
      return {
        command: scriptCommand ?? "nuxt build",
        outputDirectory: ".output",
        entrypoint: "",
        source: "Nuxt defaults",
      };
    case "astro":
      return {
        command: scriptCommand ?? "astro build",
        outputDirectory: "dist",
        entrypoint: "",
        source: "Astro defaults",
      };
    case "nestjs":
      return {
        command: scriptCommand ?? "nest build",
        outputDirectory: "dist",
        entrypoint: "",
        source: "NestJS defaults",
      };
    case "tanstack-start":
      return {
        command: scriptCommand ?? "vite build",
        outputDirectory: ".output",
        entrypoint: "",
        source: "TanStack Start defaults",
      };
    case "custom":
      return {
        command: scriptCommand,
        outputDirectory: "dist",
        entrypoint: "server.js",
        source: "Custom defaults",
      };
    case "bun":
      return {
        command: scriptCommand,
        outputDirectory: ".",
        entrypoint: "",
        source: "Bun defaults",
      };
  }
}

function resolveRemotePackageManager(args: {
  manifest: ParsedManifest;
  rootManifest: ParsedManifest | undefined;
  filePaths: Set<string>;
}): "npm" | "pnpm" | "yarn" | "bun" | null {
  const fromField = packageManagerFromField(args.manifest.value.packageManager);
  if (fromField) return fromField;
  const fromRoot = packageManagerFromField(
    args.rootManifest?.value.packageManager,
  );
  if (fromRoot) return fromRoot;

  for (const root of pathLineage(args.manifest.root)) {
    if (
      args.filePaths.has(joinRepoPath(root, "bun.lock")) ||
      args.filePaths.has(joinRepoPath(root, "bun.lockb"))
    ) {
      return "bun";
    }
    if (args.filePaths.has(joinRepoPath(root, "pnpm-lock.yaml"))) return "pnpm";
    if (args.filePaths.has(joinRepoPath(root, "yarn.lock"))) return "yarn";
    if (args.filePaths.has(joinRepoPath(root, "package-lock.json"))) {
      return "npm";
    }
  }
  return null;
}

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

function readBuildScript(scripts: unknown): string | null {
  if (!scripts || typeof scripts !== "object" || !("build" in scripts)) {
    return null;
  }
  return typeof scripts.build === "string" && scripts.build.trim()
    ? scripts.build.trim()
    : null;
}

function pathLineage(root: string): string[] {
  const parts = root.split("/").filter(Boolean);
  const lineage: string[] = [];
  for (let length = parts.length; length >= 0; length--) {
    lineage.push(parts.slice(0, length).join("/"));
  }
  return lineage;
}

function directoryName(path: string): string {
  const index = path.lastIndexOf("/");
  return index === -1 ? "" : path.slice(0, index);
}

function lastPathSegment(path: string): string | null {
  return path.split("/").filter(Boolean).at(-1) ?? null;
}

function pathDepth(path: string): number {
  return path.split("/").length;
}

function joinRepoPath(...parts: string[]): string {
  return parts.filter(Boolean).join("/").replace(/\/+/g, "/");
}

function isPathInside(path: string, root: string): boolean {
  return path === root || path.startsWith(`${root}/`);
}

function normalizeTarget(value: string): string {
  const unscoped = value.split("/").filter(Boolean).at(-1) ?? "app";
  const normalized = unscoped
    .toLowerCase()
    .replace(/[^a-z0-9-]+/g, "-")
    .replace(/^-+|-+$/g, "");
  return normalized || "app";
}

function uniquifyTargets(apps: DetectedComputeRepositoryApp[]): void {
  const reservedTargets = new Set(apps.map((app) => app.target));
  const usedTargets = new Set<string>();
  for (const app of apps) {
    const baseTarget = app.target;
    let target = baseTarget;
    let suffix = 2;
    while (usedTargets.has(target)) {
      do {
        target = `${baseTarget}-${suffix}`;
        suffix += 1;
      } while (usedTargets.has(target) || reservedTargets.has(target));
    }
    usedTargets.add(target);
    app.target = target;
    app.name = target;
  }
}
