import crypto from "crypto";
import fs from "fs";
import path from "path";
import { build } from "esbuild";
import { execa, execaCommand } from "execa";
import { fileURLToPath } from "url";
import { resolveServiceCwd } from "../config/index.mjs";
import {
  announceResolvedToolchain,
  applyToolchainEnv,
  resolveConfiguredToolchain,
} from "../toolchains/index.mjs";

const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const ROOT_ENTRY = path.join(PACKAGE_ROOT, "lib", "index.mjs");
const SETUP_ENTRY = path.join(PACKAGE_ROOT, "lib", "setup", "index.mjs");
const RUNTIME_ENTRY = path.join(PACKAGE_ROOT, "lib", "runtime", "index.mjs");
const KNOWN_FAILURES_ENTRY = path.join(PACKAGE_ROOT, "lib", "known-failures", "index.mjs");
const MODULE_RUNNER_ENTRY = path.join(
  PACKAGE_ROOT,
  "lib",
  "runner",
  "template-step-module-runner.mjs"
);

export async function runConfiguredSteps({ config, steps = [], env, labelPrefix }) {
  if (steps.length === 0) return;
  const resolvedToolchain = await resolveConfiguredToolchain(config);
  await announceResolvedToolchain(config, resolvedToolchain);

  for (const [index, step] of steps.entries()) {
    const label = `${labelPrefix}:${config.name}:${index + 1}`;
    console.log(`\n── ${label} ──`);
    await runConfiguredStep(config, step, env, resolvedToolchain);
  }
}

export function collectConfiguredInputs(productDir, { inputs = [], steps = [] } = {}) {
  const collected = new Set();
  for (const input of inputs) {
    collected.add(resolveConfiguredPath(productDir, null, input));
  }
  for (const step of steps) {
    if (step.kind === "sql-file") {
      collected.add(resolveConfiguredPath(productDir, step.cwd, step.path));
    }
    if (step.kind === "module") {
      collected.add(
        resolveConfiguredPath(productDir, step.cwd, parseModuleSpecifier(step.specifier).modulePath)
      );
    }
    for (const input of step.inputs || []) {
      collected.add(resolveConfiguredPath(productDir, step.cwd, input));
    }
  }
  return [...collected].sort();
}

export function resolveConfiguredCwd(productDir, stepCwd) {
  return resolveServiceCwd(productDir, stepCwd || ".");
}

export function resolveConfiguredPath(productDir, stepCwd, targetPath) {
  return path.resolve(resolveConfiguredCwd(productDir, stepCwd), targetPath);
}

async function runConfiguredStep(config, step, env, resolvedToolchain) {
  const runtimeEnv = applyToolchainEnv(env, resolvedToolchain);
  const cwd = resolveConfiguredCwd(config.productDir, step.cwd);

  if (step.kind === "command") {
    await execaCommand(step.cmd, {
      cwd,
      env: runtimeEnv,
      stdio: "inherit",
      shell: true,
    });
    return;
  }

  if (step.kind === "sql-file") {
    await execa(
      "psql",
      [
        runtimeEnv.DATABASE_URL,
        "-v",
        "ON_ERROR_STOP=1",
        "-X",
        "-f",
        resolveConfiguredPath(config.productDir, step.cwd, step.path),
      ],
      {
        cwd,
        env: runtimeEnv,
        stdio: "inherit",
      }
    );
    return;
  }

  if (step.kind === "module") {
    const bundledModule = await bundleConfiguredModule(config.productDir, step);
    const { exportName } = parseModuleSpecifier(step.specifier);
    const context = {
      databaseUrl: runtimeEnv.DATABASE_URL || null,
      productDir: config.productDir,
      cwd,
      serviceName: config.name,
      env: { ...runtimeEnv },
      runtimeId: config.runtimeId || null,
      stateDir: config.stateDir,
      prepareDir: config.testkit.prepareDir || null,
    };
    const contextPath = `${bundledModule.outputFile}.context.json`;
    fs.writeFileSync(contextPath, JSON.stringify(context));

    try {
      await execa(
        resolvedToolchain?.nodeExecutable || process.execPath,
        [MODULE_RUNNER_ENTRY, bundledModule.outputFile, exportName, contextPath],
        {
          cwd,
          env: runtimeEnv,
          stdio: "inherit",
        }
      );
    } finally {
      fs.rmSync(contextPath, { force: true });
    }
    return;
  }

  throw new Error(`Unsupported template step kind "${step.kind}"`);
}

async function bundleConfiguredModule(productDir, step) {
  const { modulePath } = parseModuleSpecifier(step.specifier);
  const absoluteModulePath = resolveConfiguredPath(productDir, step.cwd, modulePath);
  const bundleDir = path.join(productDir, ".testkit", "_template-steps");
  fs.mkdirSync(bundleDir, { recursive: true });

  const cacheKey = buildModuleCacheKey(absoluteModulePath);
  const outputFile = path.join(
    bundleDir,
    `${path.basename(modulePath).replace(/\W+/g, "-")}-${cacheKey.slice(0, 12)}.mjs`
  );

  await build({
    absWorkingDir: productDir,
    bundle: true,
    entryPoints: [absoluteModulePath],
    format: "esm",
    legalComments: "none",
    outfile: outputFile,
    platform: "node",
    sourcemap: "inline",
    target: "es2020",
    plugins: [testkitAliasPlugin()],
  });

  return {
    outputFile,
    cacheKey,
  };
}

function buildModuleCacheKey(modulePath) {
  const content = fs.readFileSync(modulePath, "utf8");
  return crypto.createHash("sha256").update(modulePath).update("\0").update(content).digest("hex");
}

function testkitAliasPlugin() {
  return {
    name: "testkit-template-step-alias",
    setup(buildApi) {
      buildApi.onResolve({ filter: /^@elench\/testkit(?:\/.*)?$/ }, (args) => ({
        namespace: "file",
        path: resolvePackageSubpath(args.path),
      }));
    },
  };
}

function resolvePackageSubpath(specifier) {
  const subpath = specifier.slice("@elench/testkit".length);
  if (!subpath) return ROOT_ENTRY;
  if (subpath === "/setup") return SETUP_ENTRY;
  if (subpath === "/runtime") return RUNTIME_ENTRY;
  if (subpath === "/known-failures") return KNOWN_FAILURES_ENTRY;

  throw new Error(`Unsupported @elench/testkit import "${specifier}" while loading template step`);
}

function parseModuleSpecifier(specifier) {
  const [modulePath, exportName] = String(specifier).split("#", 2);
  return {
    modulePath,
    exportName: exportName || "default",
  };
}
