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,
  reporter = null,
  setupRegistry = null,
  parentOperation = null,
}) {
  if (steps.length === 0) return;
  const resolvedToolchain = await resolveConfiguredToolchain(config);
  await announceResolvedToolchain(config, resolvedToolchain, reporter);

  for (const [index, step] of steps.entries()) {
    const label = `${labelPrefix}:${config.name}:${index + 1}`;
    const stepOperation = setupRegistry?.start({
      config,
      stage: label,
      kind: "setup-step",
      summary: summarizeConfiguredStep(step),
      parentId: parentOperation?.id || parentOperation || null,
    });
    reporter?.phaseStarted?.(label);
    try {
      await runConfiguredStep(config, step, env, resolvedToolchain, {
        reporter,
        logRecord: stepOperation?._logRecord || null,
      });
      const finished = stepOperation
        ? setupRegistry.finish(stepOperation, {
            status: "passed",
            summary: summarizeConfiguredStep(step),
          })
        : null;
      if (finished) reporter?.setupOperationFinished?.(finished);
    } catch (error) {
      const finished = stepOperation
        ? setupRegistry.finish(stepOperation, {
            status: "failed",
            summary: summarizeConfiguredStep(step),
            error: error?.message || error,
          })
        : null;
      if (finished) reporter?.setupOperationFinished?.(finished);
      throw error;
    }
  }
}

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, options = {}) {
  const runtimeEnv = applyToolchainEnv(env, resolvedToolchain);
  const cwd = resolveConfiguredCwd(config.productDir, step.cwd);
  const liveWriter =
    options.reporter?.outputMode === "debug"
      ? (line) => options.reporter.writeDebugLine?.(line)
      : null;

  if (step.kind === "command") {
    const child = execaCommand(step.cmd, {
      cwd,
      env: runtimeEnv,
      shell: true,
      stdout: "pipe",
      stderr: "pipe",
      reject: false,
    });
    await awaitCapturedProcess(child, {
      livePrefix: `[${config.runtimeLabel || config.name}:${config.name}]`,
      liveWriter,
      logRecord: options.logRecord || null,
    });
    return;
  }

  if (step.kind === "sql-file") {
    const child = execa(
      "psql",
      [
        runtimeEnv.DATABASE_URL,
        "-v",
        "ON_ERROR_STOP=1",
        "-X",
        "-f",
        resolveConfiguredPath(config.productDir, step.cwd, step.path),
      ],
      {
        cwd,
        env: runtimeEnv,
        stdout: "pipe",
        stderr: "pipe",
        reject: false,
      }
    );
    await awaitCapturedProcess(child, {
      livePrefix: `[${config.runtimeLabel || config.name}:${config.name}]`,
      liveWriter,
      logRecord: options.logRecord || null,
    });
    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 {
      const child = execa(
        resolvedToolchain?.nodeExecutable || process.execPath,
        [MODULE_RUNNER_ENTRY, bundledModule.outputFile, exportName, contextPath],
        {
          cwd,
          env: runtimeEnv,
          stdout: "pipe",
          stderr: "pipe",
          reject: false,
        }
      );
      await awaitCapturedProcess(child, {
        livePrefix: `[${config.runtimeLabel || config.name}:${config.name}]`,
        liveWriter,
        logRecord: options.logRecord || null,
      });
    } 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",
  };
}

function summarizeConfiguredStep(step) {
  if (step.kind === "command") return `command: ${String(step.cmd).trim()}`;
  if (step.kind === "sql-file") return `sql: ${step.path}`;
  if (step.kind === "module") return `module: ${step.specifier}`;
  return step.kind;
}

async function awaitCapturedProcess(child, { livePrefix = "", liveWriter = null, logRecord = null } = {}) {
  const drains = [
    captureProcessOutput(child.stdout, "stdout", livePrefix, liveWriter, logRecord),
    captureProcessOutput(child.stderr, "stderr", livePrefix, liveWriter, logRecord),
  ];
  const result = await child;
  await Promise.all(drains);
  if (result.exitCode !== 0) {
    throw new Error(result.shortMessage || result.stderr || result.stdout || `Step failed with exit code ${result.exitCode}`);
  }
}

function captureProcessOutput(stream, streamName, livePrefix, liveWriter, logRecord) {
  if (!stream) return Promise.resolve();

  let pending = "";
  return new Promise((resolve) => {
    let settled = false;
    const settle = () => {
      if (settled) return;
      settled = true;
      resolve();
    };

    stream.on("data", (chunk) => {
      pending += chunk.toString();
      const lines = pending.split(/\r?\n/);
      pending = lines.pop() || "";
      for (const line of lines) {
        if (line.length === 0) continue;
        if (logRecord) {
          logRecord.stream.write(`${new Date().toISOString()} [${streamName}] ${line}\n`);
        }
        if (liveWriter) liveWriter(livePrefix ? `${livePrefix} ${line}` : line);
      }
    });
    stream.on("end", () => {
      if (pending.length > 0) {
        if (logRecord) {
          logRecord.stream.write(`${new Date().toISOString()} [${streamName}] ${pending}\n`);
        }
        if (liveWriter) liveWriter(livePrefix ? `${livePrefix} ${pending}` : pending);
      }
      settle();
    });
    stream.on("close", settle);
    stream.on("error", settle);
  });
}
