import { existsSync } from "node:fs";
import { access, readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";

import { Result, TaggedError } from "better-result";
import { createJiti } from "jiti";

import {
  type ComputeConfigInvalidError,
  type LoadedComputeConfig,
  normalizeComputeConfig,
} from "./normalize.ts";
import { sourceRootLineage } from "./source-root.ts";

export const COMPUTE_CONFIG_FILENAME = "prisma.compute.ts";
export const COMPUTE_CONFIG_JSON_FILENAME = "prisma.compute.json";

// One config schema, two serializations: TypeScript (and its JS variants) for
// programmatic configs, JSON for static dependency-free configs that any tool
// can read and write without executing code. Exactly one file may exist.
export const COMPUTE_CONFIG_FILENAMES = [
  "prisma.compute.ts",
  "prisma.compute.mts",
  "prisma.compute.js",
  "prisma.compute.mjs",
  "prisma.compute.cjs",
  "prisma.compute.json",
] as const;

// Config files import the typed helper through this specifier; it aliases
// to this module so configs load without a local install.
const CONFIG_MODULE_SPECIFIERS = ["@prisma/compute-sdk/config"] as const;

export class ComputeConfigAmbiguousError extends TaggedError(
  "ComputeConfigAmbiguousError",
)<{
  message: string;
  configPaths: string[];
}>() {
  constructor(configPaths: string[]) {
    super({
      message: `Multiple compute config files exist: ${configPaths.map((configPath) => path.basename(configPath)).join(", ")}. Keep exactly one.`,
      configPaths,
    });
  }
}

export class ComputeConfigLoadError extends TaggedError(
  "ComputeConfigLoadError",
)<{
  message: string;
  cause: unknown;
  configPath: string;
}>() {
  constructor(configPath: string, cause: unknown) {
    super({
      message: `Could not load ${path.basename(configPath)}: ${cause instanceof Error ? cause.message : String(cause)}`,
      cause,
      configPath,
    });
  }
}

export type ComputeConfigError =
  | ComputeConfigAmbiguousError
  | ComputeConfigLoadError
  | ComputeConfigInvalidError;

/**
 * Compute config files present in one directory, in filename priority order.
 */
export async function findComputeConfigCandidates(
  directory: string,
  signal?: AbortSignal,
): Promise<string[]> {
  const candidates: string[] = [];
  for (const filename of COMPUTE_CONFIG_FILENAMES) {
    const configPath = path.join(directory, filename);
    signal?.throwIfAborted();
    try {
      await access(configPath);
      candidates.push(configPath);
    } catch (error) {
      if (signal?.aborted) throw error;
    }
  }
  signal?.throwIfAborted();

  return candidates;
}

/**
 * Locates the nearest directory holding a compute config file, searching from
 * `cwd` up to the source root. This is location-only discovery — the config
 * is not loaded or validated — so it is safe to run in hot paths.
 * Returns null when no config exists inside the repository boundary.
 */
export async function findComputeConfigDir(
  cwd: string,
  signal?: AbortSignal,
): Promise<string | null> {
  for (const directory of await sourceRootLineage(cwd, signal)) {
    const candidates = await findComputeConfigCandidates(directory, signal);
    if (candidates.length > 0) {
      return directory;
    }
  }

  return null;
}

/**
 * Loads the nearest compute config, searching from `cwd` up to the source
 * root (repository or workspace boundary). Without such a boundary only
 * `cwd` itself is checked, so discovery never escapes into unrelated
 * directories.
 */
export async function loadComputeConfig(
  cwd: string,
  options?: {
    signal?: AbortSignal;
    /**
     * Module path the config-helper import specifiers alias to. Defaults to
     * this SDK's own config module; pass a path when the consumer ships its
     * own copy of the contract (e.g. a bundled CLI).
     */
    configModuleAlias?: string;
  },
): Promise<Result<LoadedComputeConfig | null, ComputeConfigError>> {
  const signal = options?.signal;
  for (const directory of await sourceRootLineage(cwd, signal)) {
    const candidates = await findComputeConfigCandidates(directory, signal);
    if (candidates.length === 0) {
      continue;
    }
    if (candidates.length > 1) {
      return Result.err(new ComputeConfigAmbiguousError(candidates));
    }

    const configPath = candidates[0] as string;
    signal?.throwIfAborted();
    const imported =
      path.extname(configPath) === ".json"
        ? await readComputeConfigJson(configPath)
        : await importComputeConfigModule(
            configPath,
            options?.configModuleAlias,
          );
    if (imported.isErr()) {
      return Result.err(imported.error);
    }
    return normalizeComputeConfig(imported.value, configPath);
  }

  return Result.ok(null);
}

async function readComputeConfigJson(
  configPath: string,
): Promise<Result<unknown, ComputeConfigLoadError>> {
  return Result.tryPromise({
    try: async () => {
      const parsed: unknown = JSON.parse(await readFile(configPath, "utf8"));
      // "$schema" is editor tooling metadata, not config; drop it before
      // validation so JSON and TS configs share one schema exactly.
      if (
        parsed !== null &&
        typeof parsed === "object" &&
        !Array.isArray(parsed)
      ) {
        const { $schema: _schema, ...config } = parsed as Record<
          string,
          unknown
        >;
        return config;
      }
      return parsed;
    },
    catch: (cause) => new ComputeConfigLoadError(configPath, cause),
  });
}

async function importComputeConfigModule(
  configPath: string,
  configModuleAlias: string | undefined,
): Promise<Result<unknown, ComputeConfigLoadError>> {
  return Result.tryPromise({
    try: async () => {
      const aliasTarget = configModuleAlias ?? resolveOwnConfigModulePath();
      const jiti = createJiti(import.meta.url, {
        // Keep Node's standard interop so `.default` exists only for a real
        // default export (ESM) or module.exports (CJS).
        interopDefault: false,
        // Re-import fresh so repeated loads in one process observe file edits.
        moduleCache: false,
        ...(aliasTarget
          ? {
              alias: Object.fromEntries(
                CONFIG_MODULE_SPECIFIERS.map((specifier) => [
                  specifier,
                  aliasTarget,
                ]),
              ),
            }
          : {}),
      });
      const moduleNamespace =
        await jiti.import<Record<string, unknown>>(configPath);
      // Require an explicit default export instead of jiti's namespace
      // interop so named-export configs fail with a clear message.
      return moduleNamespace?.default;
    },
    catch: (cause) => new ComputeConfigLoadError(configPath, cause),
  });
}

function resolveOwnConfigModulePath(): string | null {
  // dist layout: dist/config/load.js -> dist/config/index.js
  // src layout (bun/tests): src/config/load.ts -> src/config/index.ts
  for (const candidate of ["./index.js", "./index.ts"]) {
    const candidatePath = fileURLToPath(new URL(candidate, import.meta.url));
    if (existsSync(candidatePath)) {
      return candidatePath;
    }
  }

  return null;
}
