import {
  FRAMEWORKS,
  type FrameworkBuildType,
  type FrameworkDescriptor,
  frameworkByKey,
} from "./frameworks.ts";
import type { ComputeFramework } from "./types.ts";

export interface ComputeAppManifest {
  name?: unknown;
  main?: unknown;
  packageManager?: unknown;
  workspaces?: unknown;
  scripts?: unknown;
  dependencies?: unknown;
  devDependencies?: unknown;
  peerDependencies?: unknown;
}

export interface DetectComputeAppInput {
  root: string;
  manifest: ComputeAppManifest;
  filePaths: Iterable<string>;
}

export interface DetectedComputeApp {
  framework: ComputeFramework;
  frameworkName: string;
  buildType: FrameworkBuildType;
  httpPort: number;
  entrypoint: string | null;
  evidence: {
    kind: "dependency" | "config" | "entrypoint";
    path: string;
    value: string;
  };
}

const DEFAULT_ENTRYPOINT_CANDIDATES = [
  "src/index.ts",
  "src/index.js",
  "src/server.ts",
  "src/server.js",
  "index.ts",
  "index.js",
  "server.ts",
  "server.js",
] as const;

/**
 * Detects one deployable app from an in-memory repository snapshot.
 * Paths are repository-relative so remote consumers do not need a checkout.
 */
export function detectComputeApp(
  input: DetectComputeAppInput,
): DetectedComputeApp | null {
  const root = normalizeRoot(input.root);
  const filePaths = new Set(input.filePaths);
  const dependencies = new Set([
    ...recordKeys(input.manifest.dependencies),
    ...recordKeys(input.manifest.devDependencies),
  ]);

  for (const framework of FRAMEWORKS) {
    const dependency = framework.detectPackages.find((name) =>
      dependencies.has(name),
    );
    if (dependency) {
      return detectedApp(
        framework,
        inferEntrypoint(framework, input, filePaths),
        {
          kind: "dependency",
          path: joinRepoPath(root, "package.json"),
          value: dependency,
        },
      );
    }

    const configFile = framework.detectConfigFiles.find((fileName) =>
      filePaths.has(joinRepoPath(root, fileName)),
    );
    if (configFile) {
      return detectedApp(
        framework,
        inferEntrypoint(framework, input, filePaths),
        {
          kind: "config",
          path: joinRepoPath(root, configFile),
          value: configFile,
        },
      );
    }
  }

  const runtimeScript = readRuntimeScript(input.manifest.scripts);
  if (runtimeScript) {
    const bun = frameworkByKey("bun");
    const scriptedEntrypoint = extractScriptEntrypoint(runtimeScript.command);
    const scriptReferencesExistingFile =
      scriptedEntrypoint !== null &&
      filePaths.has(joinRepoPath(root, scriptedEntrypoint));
    const entrypoint = scriptReferencesExistingFile
      ? scriptedEntrypoint
      : inferEntrypoint(bun, input, filePaths);
    if (entrypoint) {
      return detectedApp(bun, entrypoint, {
        kind: "entrypoint",
        path: joinRepoPath(root, entrypoint),
        value: `scripts.${runtimeScript.name}`,
      });
    }
  }

  return null;
}

function detectedApp(
  framework: FrameworkDescriptor,
  entrypoint: string | null,
  evidence: DetectedComputeApp["evidence"],
): DetectedComputeApp {
  return {
    framework: framework.key,
    frameworkName: framework.displayName,
    buildType: framework.buildType,
    httpPort: framework.defaultHttpPort,
    entrypoint,
    evidence,
  };
}

function inferEntrypoint(
  framework: FrameworkDescriptor,
  input: DetectComputeAppInput,
  filePaths: Set<string>,
): string | null {
  if (!framework.usesEntrypoint) {
    return null;
  }
  const root = normalizeRoot(input.root);
  if (typeof input.manifest.main === "string") {
    const main = normalizeEntrypoint(input.manifest.main);
    if (main && filePaths.has(joinRepoPath(root, main))) {
      return main;
    }
  }
  const candidates = [
    framework.defaultEntrypoint,
    ...DEFAULT_ENTRYPOINT_CANDIDATES,
  ].filter((value): value is string => Boolean(value));
  return (
    candidates.find((candidate) =>
      filePaths.has(joinRepoPath(root, candidate)),
    ) ?? null
  );
}

export function computeAppCandidateFilePaths(
  manifest: ComputeAppManifest,
): string[] {
  const main =
    typeof manifest.main === "string"
      ? normalizeEntrypoint(manifest.main)
      : null;
  const runtimeScript = readRuntimeScript(manifest.scripts);
  const scriptedEntrypoint = runtimeScript
    ? extractScriptEntrypoint(runtimeScript.command)
    : null;
  return [
    ...FRAMEWORKS.flatMap((framework) => framework.detectConfigFiles),
    ...FRAMEWORKS.map((framework) => framework.defaultEntrypoint),
    ...DEFAULT_ENTRYPOINT_CANDIDATES,
    main,
    scriptedEntrypoint,
  ].filter(
    (value, index, values): value is string =>
      Boolean(value) && values.indexOf(value) === index,
  );
}

function normalizeEntrypoint(value: string): string | null {
  if (
    !value ||
    value.startsWith("/") ||
    value.startsWith("\\") ||
    value.includes("\\") ||
    /^[A-Za-z]:/.test(value)
  ) {
    return null;
  }
  const normalized = value.replace(/^\.\/+/, "").replace(/\/+/g, "/");
  if (
    !normalized ||
    normalized === "." ||
    normalized.split("/").some((segment) => segment === "..")
  ) {
    return null;
  }
  return normalized;
}

function readRuntimeScript(
  scripts: unknown,
): { name: "start" | "serve"; command: string } | null {
  if (!scripts || typeof scripts !== "object") {
    return null;
  }
  const values = scripts as Record<string, unknown>;
  for (const name of ["start", "serve"] as const) {
    const command = values[name];
    if (typeof command === "string" && command.trim()) {
      return { name, command };
    }
  }
  return null;
}

const SCRIPT_OPTIONS_WITH_FILE_ARGUMENT = [
  "--experimental-loader",
  "--import",
  "--loader",
  "--preload",
  "--require",
  "-r",
] as const;

function extractScriptEntrypoint(command: string): string | null {
  const tokens = command.match(/"[^"]*"|'[^']*'|`[^`]*`|\S+/g) ?? [];
  const candidates: string[] = [];
  let skipNextToken = false;

  for (const rawToken of tokens) {
    if (skipNextToken) {
      skipNextToken = false;
      continue;
    }

    const quote = rawToken[0];
    const token =
      (quote === '"' || quote === "'" || quote === "`") &&
      rawToken.at(-1) === quote
        ? rawToken.slice(1, -1)
        : rawToken;
    const optionWithFileArgument = SCRIPT_OPTIONS_WITH_FILE_ARGUMENT.find(
      (option) => token === option || token.startsWith(`${option}=`),
    );
    if (optionWithFileArgument) {
      skipNextToken = token === optionWithFileArgument;
      continue;
    }
    if (!/^(?:\.{1,2}\/)?[\w@./-]+\.(?:[cm]?[jt]sx?)$/.test(token)) {
      continue;
    }

    const candidate = normalizeEntrypoint(token);
    if (candidate) {
      candidates.push(candidate);
    }
  }

  return candidates.length === 1 ? (candidates[0] ?? null) : null;
}

function recordKeys(value: unknown): string[] {
  return value && typeof value === "object" ? Object.keys(value) : [];
}

function normalizeRoot(root: string): string {
  const normalized = root.replace(/^\.?\/+|\/+$/g, "");
  return normalized === "." ? "" : normalized;
}

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