import crypto from "crypto";
import fs from "fs";
import os from "os";
import path from "path";
import { execa } from "execa";
import { resolveServiceCwd } from "../config/index.mjs";
import {
  deriveSemverMinimum,
  formatSemver,
  isExactSemver,
  normalizeSemver,
  satisfiesSemver,
} from "./semver.mjs";

const DEFAULT_TOOLCHAIN_CWD = ".";
const DEFAULT_TOOLCHAIN_DETECT = "auto";
const DEFAULT_TOOLCHAIN_INSTALL = "require-host";

const resolvedToolchainPromises = new WeakMap();
const announcedToolchains = new WeakSet();

export function nodeToolchain(options = {}) {
  return {
    kind: "node",
    ...options,
  };
}

export function normalizeToolchainRegistry(value) {
  if (value == null) return {};
  if (!value || typeof value !== "object" || Array.isArray(value)) {
    throw new Error("testkit.setup.ts toolchains must be an object");
  }

  const normalized = {};
  for (const [name, config] of Object.entries(value)) {
    normalized[name] = normalizeToolchainConfig(config, `testkit.setup.ts toolchains.${name}`);
  }
  return normalized;
}

export function normalizeRuntimeToolchain(value, label, registry = {}) {
  if (value == null) return null;

  if (typeof value === "string") {
    const name = value.trim();
    if (!name) {
      throw new Error(`${label} must reference a non-empty toolchain name`);
    }
    const referenced = registry[name];
    if (!referenced) {
      throw new Error(`${label} references unknown toolchain "${name}"`);
    }
    return {
      ...referenced,
      refName: name,
      sourceLabel: `${label} -> ${name}`,
    };
  }

  const normalized = normalizeToolchainConfig(value, label);
  return {
    ...normalized,
    refName: null,
    sourceLabel: label,
  };
}

export async function resolveConfiguredToolchain(config, options = {}) {
  const toolchain = config?.testkit?.runtime?.toolchain;
  if (!toolchain) return null;

  const existing = resolvedToolchainPromises.get(config);
  if (existing) return existing;

  const promise = resolveToolchain(config, toolchain, options);
  resolvedToolchainPromises.set(config, promise);

  try {
    return await promise;
  } catch (error) {
    resolvedToolchainPromises.delete(config);
    throw error;
  }
}

export async function announceResolvedToolchain(config, resolvedToolchain, reporter = null) {
  if (!resolvedToolchain || announcedToolchains.has(config)) return;
  announcedToolchains.add(config);
  if (reporter?.toolchainResolved) {
    reporter.toolchainResolved(config, resolvedToolchain);
  }
}

export function applyToolchainEnv(baseEnv, resolvedToolchain, processEnv = process.env) {
  if (!resolvedToolchain) return { ...baseEnv };

  const env = {
    ...baseEnv,
    PATH: prependPathEntry(
      resolvedToolchain.binDir,
      baseEnv.PATH || processEnv.PATH || ""
    ),
    TESTKIT_TOOLCHAIN_KIND: resolvedToolchain.kind,
    TESTKIT_TOOLCHAIN_NODE_VERSION: resolvedToolchain.nodeVersion,
    TESTKIT_TOOLCHAIN_NODE_SOURCE: resolvedToolchain.nodeSource,
    TESTKIT_TOOLCHAIN_INSTALL_MODE: resolvedToolchain.install,
  };

  if (resolvedToolchain.npmVersion) {
    env.TESTKIT_TOOLCHAIN_NPM_VERSION = resolvedToolchain.npmVersion;
    env.TESTKIT_TOOLCHAIN_NPM_SOURCE = resolvedToolchain.npmSource;
  }

  return env;
}

function normalizeToolchainConfig(value, label) {
  if (!value || typeof value !== "object" || Array.isArray(value)) {
    throw new Error(`${label} must be an object`);
  }

  const kind = normalizeOptionalString(value.kind) || "node";
  if (kind !== "node") {
    throw new Error(`${label}.kind must be "node"`);
  }

  const cwd = normalizeOptionalString(value.cwd) || DEFAULT_TOOLCHAIN_CWD;
  const detect = normalizeOptionalString(value.detect) || DEFAULT_TOOLCHAIN_DETECT;
  const install = normalizeOptionalString(value.install) || DEFAULT_TOOLCHAIN_INSTALL;

  if (!["auto", "off"].includes(detect)) {
    throw new Error(`${label}.detect must be "auto" or "off"`);
  }
  if (!["require-host", "download"].includes(install)) {
    throw new Error(`${label}.install must be "require-host" or "download"`);
  }

  return {
    kind: "node",
    cwd,
    detect,
    install,
    node: normalizeOptionalString(value.node),
    npm: normalizeOptionalString(value.npm),
  };
}

async function resolveToolchain(config, toolchain, options) {
  if (toolchain.kind !== "node") {
    throw new Error(`Unsupported toolchain kind "${toolchain.kind}"`);
  }

  const productDir = config.productDir;
  const cwd = resolveServiceCwd(productDir, toolchain.cwd || DEFAULT_TOOLCHAIN_CWD);
  const detected = detectNodeToolchainMetadata(cwd);
  const nodeSpec = toolchain.node || (toolchain.detect === "auto" ? detected.nodeSpec : null);
  const nodeSource =
    (toolchain.node && "toolchain.node") ||
    (toolchain.detect === "auto" ? detected.nodeSource : null) ||
    "none";
  const npmSpec = toolchain.npm || (toolchain.detect === "auto" ? detected.npmSpec : null);
  const npmSource =
    (toolchain.npm && "toolchain.npm") ||
    (toolchain.detect === "auto" ? detected.npmSource : null) ||
    "none";

  if (!nodeSpec && !npmSpec) {
    throw new Error(
      `Service "${config.name}" enables runtime.toolchain but no Node/npm version could be resolved in ${toolchain.cwd}`
    );
  }

  if (toolchain.install === "require-host") {
    return resolveHostNodeToolchain({
      config,
      cwd,
      nodeSpec,
      nodeSource,
      npmSpec,
      npmSource,
      toolchain,
      options,
    });
  }

  return resolveDownloadedNodeToolchain({
    config,
    cwd,
    nodeSpec,
    nodeSource,
    npmSpec,
    npmSource,
    toolchain,
    options,
  });
}

function detectNodeToolchainMetadata(cwd) {
  const packageJsonPath = path.join(cwd, "package.json");
  const packageJson = readJsonFile(packageJsonPath);
  const toolVersions = readToolVersions(path.join(cwd, ".tool-versions"));
  const nvmrc = readFirstLine(path.join(cwd, ".nvmrc"));
  const nodeVersionFile = readFirstLine(path.join(cwd, ".node-version"));

  const nodeSpec =
    normalizeOptionalString(packageJson?.volta?.node) ||
    normalizeOptionalString(nvmrc) ||
    normalizeOptionalString(nodeVersionFile) ||
    normalizeOptionalString(toolVersions.nodejs) ||
    normalizeOptionalString(packageJson?.engines?.node);

  const nodeSource =
    (normalizeOptionalString(packageJson?.volta?.node) && "package.json#volta.node") ||
    (normalizeOptionalString(nvmrc) && ".nvmrc") ||
    (normalizeOptionalString(nodeVersionFile) && ".node-version") ||
    (normalizeOptionalString(toolVersions.nodejs) && ".tool-versions#nodejs") ||
    (normalizeOptionalString(packageJson?.engines?.node) && "package.json#engines.node") ||
    null;

  const packageManager = parsePackageManager(packageJson?.packageManager);
  const npmSpec =
    normalizeOptionalString(packageJson?.volta?.npm) ||
    (packageManager?.name === "npm" ? packageManager.version : null) ||
    normalizeOptionalString(packageJson?.engines?.npm);

  const npmSource =
    (normalizeOptionalString(packageJson?.volta?.npm) && "package.json#volta.npm") ||
    ((packageManager?.name === "npm" && packageManager.version) && "package.json#packageManager") ||
    (normalizeOptionalString(packageJson?.engines?.npm) && "package.json#engines.npm") ||
    null;

  return {
    nodeSpec,
    nodeSource,
    npmSpec,
    npmSource,
  };
}

async function resolveHostNodeToolchain({
  config,
  cwd,
  nodeSpec,
  nodeSource,
  npmSpec,
  npmSource,
  toolchain,
  options,
}) {
  const hostNodeVersion = normalizeSemver(options.hostNodeVersion || process.versions.node);
  if (nodeSpec && !satisfiesSemver(hostNodeVersion, nodeSpec)) {
    throw new Error(
      `Service "${config.name}" requires Node ${nodeSpec} (${nodeSource}), but host Node is ${hostNodeVersion}`
    );
  }

  let hostNpmVersion = null;
  if (npmSpec) {
    hostNpmVersion = normalizeSemver(
      options.hostNpmVersion ||
        (await readCommandVersion({
          command: "npm",
          args: ["--version"],
          cwd,
          env: process.env,
        }))
    );
    if (!satisfiesSemver(hostNpmVersion, npmSpec)) {
      throw new Error(
        `Service "${config.name}" requires npm ${npmSpec} (${npmSource}), but host npm is ${hostNpmVersion}`
      );
    }
  }

  return {
    kind: "node",
    install: toolchain.install,
    cwd: toolchain.cwd,
    nodeVersion: hostNodeVersion,
    nodeSource,
    nodeSpec,
    npmVersion: hostNpmVersion,
    npmSource,
    npmSpec,
    nodeExecutable: process.execPath,
    binDir: path.dirname(process.execPath),
    toolchainDir: path.dirname(process.execPath),
    fingerprint: buildToolchainFingerprint({
      kind: "node",
      install: toolchain.install,
      nodeVersion: hostNodeVersion,
      npmVersion: hostNpmVersion,
      nodeSource,
      npmSource,
    }),
    summary: `${toolchain.install} node=${hostNodeVersion}${hostNpmVersion ? ` npm=${hostNpmVersion}` : ""} (${nodeSource})`,
  };
}

async function resolveDownloadedNodeToolchain({
  config,
  cwd,
  nodeSpec,
  nodeSource,
  npmSpec,
  npmSource,
  toolchain,
  options,
}) {
  if (!nodeSpec) {
    throw new Error(
      `Service "${config.name}" download toolchain mode requires a resolvable Node version`
    );
  }

  const requestedNodeVersion = isExactSemver(nodeSpec)
    ? normalizeSemver(nodeSpec)
    : deriveSemverMinimum(nodeSpec);
  if (!requestedNodeVersion) {
    throw new Error(
      `Service "${config.name}" cannot deterministically provision Node from "${nodeSpec}". ` +
        `Provide an exact version in runtime.toolchain.node, .nvmrc, .node-version, .tool-versions, or package.json#volta.node.`
    );
  }

  const provisioned = await provisionNodeToolchain({
    productDir: config.productDir,
    version: requestedNodeVersion,
    fetchImpl: options.fetchImpl || fetch,
    execaImpl: options.execaImpl || execa,
  });

  if (!satisfiesSemver(requestedNodeVersion, nodeSpec)) {
    throw new Error(
      `Service "${config.name}" resolved Node ${requestedNodeVersion}, which does not satisfy ${nodeSpec}`
    );
  }

  const npmVersion = provisioned.npmVersion ? normalizeSemver(provisioned.npmVersion) : null;
  if (npmSpec && (!npmVersion || !satisfiesSemver(npmVersion, npmSpec))) {
    throw new Error(
      `Service "${config.name}" requires npm ${npmSpec} (${npmSource}), but Node ${requestedNodeVersion} bundles npm ${npmVersion || "unknown"}`
    );
  }

  return {
    kind: "node",
    install: toolchain.install,
    cwd: toolchain.cwd,
    nodeVersion: requestedNodeVersion,
    nodeSource,
    nodeSpec,
    npmVersion,
    npmSource,
    npmSpec,
    nodeExecutable: provisioned.nodeExecutable,
    binDir: provisioned.binDir,
    toolchainDir: provisioned.toolchainDir,
    fingerprint: buildToolchainFingerprint({
      kind: "node",
      install: toolchain.install,
      nodeVersion: requestedNodeVersion,
      npmVersion,
      nodeSource,
      npmSource,
    }),
    summary:
      `download node=${requestedNodeVersion}${npmVersion ? ` npm=${npmVersion}` : ""} ` +
      `(${nodeSource})`,
  };
}

async function provisionNodeToolchain({ productDir, version, fetchImpl, execaImpl }) {
  const rootDir = path.join(productDir, ".testkit", "_toolchains", "node");
  const installDir = path.join(rootDir, `${version}-${platformArchiveLabel()}`);
  const manifestPath = path.join(installDir, "toolchain.json");

  if (fs.existsSync(manifestPath)) {
    return readProvisionedNodeToolchain(installDir);
  }

  fs.mkdirSync(rootDir, { recursive: true });
  const lockPath = `${installDir}.lock`;

  return withFileLock(lockPath, async () => {
    if (fs.existsSync(manifestPath)) {
      return readProvisionedNodeToolchain(installDir);
    }

    const tempDir = path.join(rootDir, `.tmp-${version}-${crypto.randomUUID()}`);
    const archivePath = `${tempDir}.tar.gz`;

    fs.mkdirSync(tempDir, { recursive: true });
    try {
      const downloadUrl = buildNodeDownloadUrl(version);
      const response = await fetchImpl(downloadUrl);
      if (!response.ok) {
        throw new Error(`Failed to download ${downloadUrl}: HTTP ${response.status}`);
      }

      const body = Buffer.from(await response.arrayBuffer());
      fs.writeFileSync(archivePath, body);

      await execaImpl("tar", ["-xzf", archivePath, "-C", tempDir, "--strip-components=1"]);
      fs.rmSync(archivePath, { force: true });
      fs.rmSync(installDir, { recursive: true, force: true });
      fs.renameSync(tempDir, installDir);

      const npmVersion = await readCommandVersion({
        command: path.join(nodeBinDir(installDir), "npm"),
        args: ["--version"],
        cwd: installDir,
        env: process.env,
      });

      const manifest = {
        kind: "node",
        version,
        npmVersion: normalizeSemver(npmVersion),
        platform: process.platform,
        arch: process.arch,
      };
      fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
      return readProvisionedNodeToolchain(installDir);
    } catch (error) {
      fs.rmSync(tempDir, { recursive: true, force: true });
      fs.rmSync(archivePath, { force: true });
      throw error;
    }
  });
}

function readProvisionedNodeToolchain(installDir) {
  const manifest = readJsonFile(path.join(installDir, "toolchain.json"));
  return {
    toolchainDir: installDir,
    binDir: nodeBinDir(installDir),
    nodeExecutable: path.join(nodeBinDir(installDir), process.platform === "win32" ? "node.exe" : "node"),
    npmVersion: manifest?.npmVersion || null,
  };
}

function nodeBinDir(installDir) {
  if (process.platform === "win32") return installDir;
  return path.join(installDir, "bin");
}

function buildNodeDownloadUrl(version) {
  if (!["linux", "darwin"].includes(process.platform)) {
    throw new Error(`Node toolchain download is not supported on ${process.platform}`);
  }

  const archivePlatform =
    process.platform === "darwin" ? "darwin" : process.platform;
  const archiveArch = process.arch === "arm64" ? "arm64" : process.arch === "x64" ? "x64" : null;
  if (!archiveArch) {
    throw new Error(`Node toolchain download is not supported on ${process.platform}/${process.arch}`);
  }

  return `https://nodejs.org/dist/v${version}/node-v${version}-${archivePlatform}-${archiveArch}.tar.gz`;
}

function platformArchiveLabel() {
  return `${process.platform}-${process.arch}`;
}

function buildToolchainFingerprint(value) {
  return crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex");
}

async function readCommandVersion({ command, args, cwd, env }) {
  const result = await execa(command, args, {
    cwd,
    env,
    stdio: "pipe",
  });
  return String(result.stdout || "").trim();
}

function parsePackageManager(value) {
  const normalized = normalizeOptionalString(value);
  if (!normalized) return null;
  const atIndex = normalized.lastIndexOf("@");
  if (atIndex <= 0 || atIndex === normalized.length - 1) return null;
  return {
    name: normalized.slice(0, atIndex),
    version: normalized.slice(atIndex + 1),
  };
}

function readJsonFile(filePath) {
  if (!fs.existsSync(filePath)) return null;
  try {
    return JSON.parse(fs.readFileSync(filePath, "utf8"));
  } catch {
    return null;
  }
}

function readFirstLine(filePath) {
  if (!fs.existsSync(filePath)) return null;
  const content = fs.readFileSync(filePath, "utf8");
  const line = content.split(/\r?\n/).find((entry) => entry.trim().length > 0);
  return line ? line.trim() : null;
}

function readToolVersions(filePath) {
  if (!fs.existsSync(filePath)) return {};
  const values = {};
  for (const line of fs.readFileSync(filePath, "utf8").split(/\r?\n/)) {
    const trimmed = line.trim();
    if (!trimmed || trimmed.startsWith("#")) continue;
    const [tool, version] = trimmed.split(/\s+/, 2);
    if (tool && version) {
      values[tool] = version.trim();
    }
  }
  return values;
}

function prependPathEntry(entry, currentPath) {
  const normalized = normalizeOptionalString(entry);
  if (!normalized) return currentPath;
  const parts = String(currentPath || "")
    .split(path.delimiter)
    .filter(Boolean)
    .filter((value) => value !== normalized);
  return [normalized, ...parts].join(path.delimiter);
}

function normalizeOptionalString(value) {
  if (typeof value !== "string") return null;
  const normalized = value.trim();
  return normalized.length > 0 ? normalized : null;
}

async function withFileLock(lockPath, fn) {
  const startedAt = Date.now();
  while (true) {
    try {
      const fd = fs.openSync(lockPath, "wx");
      fs.writeFileSync(fd, JSON.stringify({
        pid: process.pid,
        host: os.hostname(),
        createdAt: new Date().toISOString(),
      }));
      fs.closeSync(fd);

      try {
        return await fn();
      } finally {
        fs.rmSync(lockPath, { force: true });
      }
    } catch (error) {
      if (error?.code !== "EEXIST") throw error;
    }

    if (Date.now() - startedAt > 60_000) {
      throw new Error(`Timed out waiting for toolchain lock ${lockPath}`);
    }
    await new Promise((resolve) => setTimeout(resolve, 100));
  }
}
