import {
  access,
  mkdir,
  mkdtemp,
  readdir,
  readFile,
  rm,
} from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { BuildSettings } from "./build-settings.ts";
import {
  type BuildArtifact,
  type BuildStrategy,
  runBuildSettingsCommand,
} from "./build-strategy.ts";
import { defaultHttpPortForBuildType } from "./config/frameworks.ts";
import { stageConfiguredArtifact } from "./configured-artifact.ts";
import {
  type BuildCommandIo,
  buildCommandEnv,
  runChildProcess,
} from "./workspace.ts";

/**
 * Build strategy that runs `bun build` CLI and manages its own temp output directory.
 * Owns entrypoint resolution from explicit arg or package.json main field.
 *
 * When build settings carry a build command (a `package.json` build script),
 * it runs first — so a Bun app can compile assets or generate code before the
 * entrypoint is bundled.
 */
export class BunBuild implements BuildStrategy {
  readonly #appPath: string;
  readonly #entrypoint?: string;
  readonly #buildSettings?: BuildSettings;
  readonly #io?: BuildCommandIo;

  constructor(options: {
    appPath: string;
    entrypoint?: string;
    buildSettings?: BuildSettings;
    io?: BuildCommandIo;
  }) {
    this.#appPath = options.appPath;
    this.#entrypoint = options.entrypoint;
    this.#buildSettings = options.buildSettings;
    this.#io = options.io;
  }

  async canBuild(_signal?: AbortSignal): Promise<boolean> {
    return true;
  }

  async execute(signal?: AbortSignal): Promise<BuildArtifact> {
    signal?.throwIfAborted();
    if (this.#buildSettings) {
      await runBuildSettingsCommand({
        appPath: this.#appPath,
        settings: this.#buildSettings,
        failurePrefix: "Bun",
        io: this.#io,
        signal,
      });
    }

    if (this.#buildSettings && this.#buildSettings.outputDirectory !== ".") {
      if (!this.#buildSettings.entrypoint) {
        throw new Error(
          "Bun build entrypoint is required when outputDirectory is configured",
        );
      }
      return stageConfiguredArtifact({
        appPath: this.#appPath,
        outputDirectory: this.#buildSettings.outputDirectory,
        entrypoint: this.#buildSettings.entrypoint,
        buildType: "bun",
        label: "Bun",
        signal,
      });
    }

    const entrypoint = await this.#resolveEntrypoint(
      signal,
      this.#buildSettings?.entrypoint,
    );

    const outDir = await mkdtemp(path.join(os.tmpdir(), "compute-build-"));
    const bundleDir = path.join(outDir, "bundle");
    await mkdir(bundleDir, { recursive: true });

    const absoluteEntrypoint = path.join(this.#appPath, entrypoint);

    try {
      await this.#runBuild(absoluteEntrypoint, bundleDir, signal);
    } catch (error) {
      await rm(outDir, { recursive: true, force: true });
      throw error;
    }

    const outputFiles = await readdir(bundleDir);
    const jsOutputs = outputFiles
      .filter((f) => f.endsWith(".js"))
      .map((f) => path.join(bundleDir, f));

    const runtimeEntrypoint = this.#selectRuntimeEntrypoint(
      jsOutputs,
      bundleDir,
      absoluteEntrypoint,
    );

    return {
      directory: bundleDir,
      entrypoint: runtimeEntrypoint,
      defaultPortMapping: { http: defaultHttpPortForBuildType("bun") },
      cleanup: () => rm(outDir, { recursive: true, force: true }),
    };
  }

  async #resolveEntrypoint(
    signal?: AbortSignal,
    configuredEntrypoint?: string,
  ): Promise<string> {
    signal?.throwIfAborted();
    const candidate =
      this.#entrypoint ??
      configuredEntrypoint ??
      (await this.#readPackageJsonMain());

    if (!candidate) {
      throw new Error(
        "Entrypoint is required. Pass --entrypoint or define package.json main",
      );
    }

    if (path.isAbsolute(candidate)) {
      throw new Error("Entrypoint must be a relative path");
    }

    const normalized = path.normalize(candidate);
    if (
      normalized.startsWith("..") ||
      path.isAbsolute(normalized) ||
      normalized.includes(`${path.sep}..${path.sep}`)
    ) {
      throw new Error("Entrypoint must not escape the app directory");
    }

    const entrypointPath = path.join(this.#appPath, normalized);
    try {
      await access(entrypointPath);
    } catch {
      throw new Error(`Entrypoint file does not exist: ${entrypointPath}`);
    }

    return normalized.split(path.sep).join("/");
  }

  async #readPackageJsonMain(): Promise<string | undefined> {
    const packageJsonPath = path.join(this.#appPath, "package.json");

    let content: string;
    try {
      content = await readFile(packageJsonPath, "utf-8");
    } catch (error) {
      if (
        error instanceof Error &&
        "code" in error &&
        error.code === "ENOENT"
      ) {
        return undefined;
      }
      throw new Error(
        `Failed to read ${packageJsonPath}: ${error instanceof Error ? error.message : String(error)}`,
      );
    }

    let parsed: { main?: unknown };
    try {
      parsed = JSON.parse(content) as { main?: unknown };
    } catch (error) {
      throw new Error(
        `Failed to parse ${packageJsonPath}: ${error instanceof Error ? error.message : String(error)}`,
      );
    }

    return typeof parsed.main === "string" ? parsed.main : undefined;
  }

  async #runBuild(
    absoluteEntrypoint: string,
    bundleDir: string,
    signal?: AbortSignal,
  ): Promise<void> {
    const env = await buildCommandEnv(
      this.#appPath,
      { ...process.env, ...this.#io?.env },
      signal,
    );
    try {
      await runChildProcess({
        command: "bun",
        args: [
          "build",
          absoluteEntrypoint,
          "--outdir",
          bundleDir,
          "--target",
          "bun",
          "--sourcemap=external",
        ],
        cwd: this.#appPath,
        env,
        failurePrefix: "Bun",
        onOutput: this.#io?.onOutput,
        signal,
      });
    } catch (error) {
      if (
        error instanceof Error &&
        "code" in error &&
        (error as NodeJS.ErrnoException).code === "ENOENT"
      ) {
        throw new Error(
          "Bun is required to build the application but was not found. Install it from https://bun.sh",
        );
      }
      throw error;
    }
  }

  #selectRuntimeEntrypoint(
    outputPaths: string[],
    bundleDir: string,
    absoluteEntrypoint: string,
  ): string {
    const jsOutputs = outputPaths.filter((output) => output.endsWith(".js"));
    if (jsOutputs.length === 0) {
      throw new Error("Bun build produced no JavaScript output");
    }

    const expectedBasename = `${path.basename(absoluteEntrypoint, path.extname(absoluteEntrypoint))}.js`;
    const matchingByName = jsOutputs.find(
      (output) => path.basename(output) === expectedBasename,
    );
    const selected = matchingByName ?? jsOutputs[0];

    if (!selected) {
      throw new Error("Unable to determine built entrypoint");
    }

    const relative = path.relative(bundleDir, selected);
    if (relative.startsWith("..") || path.isAbsolute(relative)) {
      throw new Error("Built entrypoint is outside the bundle directory");
    }

    return relative.split(path.sep).join("/");
  }
}
