const crypto = require("node:crypto");

const FIXED_DH_GROUP_BY_KEX = Object.freeze({
  "diffie-hellman-group1-sha1": "modp2",
  "diffie-hellman-group14-sha1": "modp14",
  "diffie-hellman-group14-sha256": "modp14",
  "diffie-hellman-group16-sha512": "modp16",
  "diffie-hellman-group18-sha512": "modp18",
});

const CIPHER_SSL_NAME_BY_ALGORITHM = Object.freeze({
  "chacha20-poly1305@openssh.com": "chacha20",
  "aes128-gcm@openssh.com": "aes-128-gcm",
  "aes256-gcm@openssh.com": "aes-256-gcm",
  "aes128-gcm": "aes-128-gcm",
  "aes256-gcm": "aes-256-gcm",
  "aes128-ctr": "aes-128-ctr",
  "aes192-ctr": "aes-192-ctr",
  "aes256-ctr": "aes-256-ctr",
  "aes128-cbc": "aes-128-cbc",
  "aes192-cbc": "aes-192-cbc",
  "aes256-cbc": "aes-256-cbc",
  "3des-cbc": "des-ede3-cbc",
});

let _md5Supported = null;
let _supportedCiphers = null;
const dhGroupSupport = new Map();

// MODP groups that every SSH runtime we target supports, so we skip the
// feature-detection probe for them. Under Electron's BoringSSL, instantiating a
// fixed DH group object purely to test support is pathologically slow — the
// 8192-bit modp18 alone takes ~20s on first call, freezing the first connection
// of every app launch — yet the probe always succeeds. We only feature-detect
// groups a runtime might genuinely drop (e.g. BoringSSL removed the weak
// 1024-bit group1/modp2); those fail their probe instantly, so it stays cheap.
const ASSUMED_SUPPORTED_DH_GROUPS = new Set(["modp14", "modp16", "modp18"]);

// FIPS-enabled OpenSSL builds disable MD5. Feature-detect once so the legacy
// algorithm list can skip hmac-md5 on those builds; ssh2 validates exact
// algorithm lists strictly and would otherwise throw "Unsupported algorithm"
// before the SSH handshake even starts.
function md5Supported() {
  if (_md5Supported === null) {
    try { _md5Supported = crypto.getHashes().includes("md5"); }
    catch { _md5Supported = false; }
  }
  return _md5Supported;
}

function fixedDhGroupSupported(groupName) {
  if (ASSUMED_SUPPORTED_DH_GROUPS.has(groupName)) return true;
  if (!dhGroupSupport.has(groupName)) {
    try {
      crypto.createDiffieHellmanGroup(groupName);
      dhGroupSupport.set(groupName, true);
    } catch {
      dhGroupSupport.set(groupName, false);
    }
  }
  return dhGroupSupport.get(groupName);
}

function filterSupportedFixedDhKex(kexAlgorithms) {
  return kexAlgorithms.filter((kexName) => {
    const groupName = FIXED_DH_GROUP_BY_KEX[kexName];
    return !groupName || fixedDhGroupSupported(groupName);
  });
}

function supportedCiphers() {
  if (_supportedCiphers === null) {
    try { _supportedCiphers = new Set(crypto.getCiphers()); }
    catch { _supportedCiphers = new Set(); }
  }
  return _supportedCiphers;
}

function filterRuntimeUnsupportedCiphers(cipherAlgorithms) {
  const ciphers = supportedCiphers();
  return cipherAlgorithms.filter((algo) => {
    const sslName = CIPHER_SSL_NAME_BY_ALGORITHM[algo];
    return !sslName || ciphers.has(sslName);
  });
}

function buildBaseAlgorithms() {
  return {
    cipher: filterRuntimeUnsupportedCiphers([
      "aes128-gcm@openssh.com", "aes256-gcm@openssh.com",
      "aes128-ctr", "aes192-ctr", "aes256-ctr",
      "chacha20-poly1305@openssh.com",
    ]),
    kex: filterSupportedFixedDhKex([
      "curve25519-sha256", "curve25519-sha256@libssh.org",
      "ecdh-sha2-nistp256", "ecdh-sha2-nistp384", "ecdh-sha2-nistp521",
      "diffie-hellman-group14-sha256",
      "diffie-hellman-group16-sha512", "diffie-hellman-group18-sha512",
      "diffie-hellman-group-exchange-sha256",
    ]),
    compress: ["none"],
  };
}

function applyLegacyAlgorithms(algorithms) {
  algorithms.kex.push(...filterSupportedFixedDhKex([
    "diffie-hellman-group14-sha1",
    "diffie-hellman-group1-sha1",
    "diffie-hellman-group-exchange-sha1",
  ]));
  algorithms.cipher.push(
    ...filterRuntimeUnsupportedCiphers([
      "aes128-cbc", "aes256-cbc", "3des-cbc",
    ]),
  );
  algorithms.serverHostKey = [
    "ssh-ed25519", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521",
    "rsa-sha2-512", "rsa-sha2-256",
    "ssh-rsa", "ssh-dss",
  ];
}

function applyLegacyHmacAlgorithms(algorithms) {
  // Legacy HMACs: required by very old servers (e.g. FreeBSD 6.1 OpenSSH
  // ~2006, issue #807). Without hmac-sha1/md5 in the offered list, the
  // handshake exchange-hash MAC never agrees and the host-key signature
  // verification that depends on it fails with
  // "Handshake failed: signature verification failed", which looks like
  // a host-key problem but is really a MAC negotiation mismatch.
  //
  // hmac-md5 is only appended when the local OpenSSL build actually
  // supports MD5. FIPS-enabled Node builds disable MD5 entirely, and
  // ssh2 strictly validates exact algorithm lists. Listing an unavailable
  // algorithm would throw "Unsupported algorithm" before any SSH
  // negotiation, turning the legacy toggle into a hard failure for FIPS
  // users. hmac-sha1 is allowed for HMAC even under FIPS 140-2 so it
  // stays unconditionally.
  // hmac-sha1-etm@openssh.com is in ssh2's default MAC set. Keep it so
  // hosts that only accept EtM SHA-1 MACs don't regress to "no matching
  // C->S MAC" when legacy mode replaces the default list.
  algorithms.hmac = [
    "hmac-sha2-256-etm@openssh.com", "hmac-sha2-512-etm@openssh.com",
    "hmac-sha2-256", "hmac-sha2-512",
    "hmac-sha1-etm@openssh.com",
    "hmac-sha1",
  ];
  if (md5Supported()) {
    algorithms.hmac.push("hmac-md5");
  }
}

// Mirror of ssh2's DEFAULT_SERVER_HOST_KEY (see ssh2/lib/protocol/constants.js)
// minus every ecdsa-sha2-*. Used when skipEcdsaHostKey is on but legacy mode
// is off, so the field would otherwise be undefined and ssh2 would fall back
// to its built-in defaults (which still include ecdsa-sha2-*).
const MODERN_SERVER_HOST_KEY_WITHOUT_ECDSA = Object.freeze([
  "ssh-ed25519",
  "rsa-sha2-512",
  "rsa-sha2-256",
  "ssh-rsa",
]);

function applyEcdsaHostKeySkip(algorithms) {
  if (algorithms.serverHostKey) {
    algorithms.serverHostKey = algorithms.serverHostKey.filter(
      (algo) => !algo.startsWith("ecdsa-sha2-"),
    );
  } else {
    algorithms.serverHostKey = [...MODERN_SERVER_HOST_KEY_WITHOUT_ECDSA];
  }
}

// Categories the user can override via host.algorithms. Mirrors ssh2's
// algorithm-object shape (note ssh2 uses 'compress', not 'compression').
const OVERRIDABLE_CATEGORIES = Object.freeze([
  "kex", "cipher", "hmac", "serverHostKey", "compress",
]);

function filterRuntimeUnsupportedHmac(list) {
  if (md5Supported()) return list;
  // FIPS-enabled Node disables MD5. ssh2's generateAlgorithmList rejects
  // hmac-md5 / hmac-md5-96 against its SUPPORTED_MAC (filtered by
  // canUseMAC at startup) and throws "Unsupported algorithm" before any
  // SSH negotiation — drop those entries from a user override the same
  // way applyLegacyHmacAlgorithms gates the legacy seed.
  return list.filter((algo) => !algo.startsWith("hmac-md5"));
}

function applyAlgorithmOverrides(algorithms, overrides) {
  if (!overrides || typeof overrides !== "object") return;
  for (const key of OVERRIDABLE_CATEGORIES) {
    const list = overrides[key];
    if (Array.isArray(list) && list.length > 0) {
      // Copy so caller mutation cannot leak back into the host config object.
      const copy = list.slice();
      let filtered;
      if (key === "kex") {
        // KEX needs the same runtime fixed-DH support filter the default
        // builder applies — BoringSSL drops modp2 (the prime backing
        // `diffie-hellman-group1-sha1`), and an override that re-introduces
        // an unsupported group would make ssh2 throw "Unknown DH group"
        // mid-handshake instead of failing fast.
        filtered = filterSupportedFixedDhKex(copy);
      } else if (key === "hmac") {
        filtered = filterRuntimeUnsupportedHmac(copy);
      } else if (key === "cipher") {
        filtered = filterRuntimeUnsupportedCiphers(copy);
      } else {
        filtered = copy;
      }
      algorithms[key] = filtered;
    }
  }
}

/**
 * Build SSH algorithm configuration.
 * When legacyEnabled is true, legacy algorithms are appended to each list
 * (lower priority than modern ones) for compatibility with older network equipment.
 *
 * @param {boolean} legacyEnabled
 * @param {{
 *   skipEcdsaHostKey?: boolean,
 *   algorithmOverrides?: Partial<Record<"kex"|"cipher"|"hmac"|"serverHostKey"|"compress", string[]>>,
 * }} [options]
 *   skipEcdsaHostKey: drop every ecdsa-sha2-* from the host-key advertisement.
 *     Useful when a server (e.g. old Huawei VRP) negotiates ECDSA but produces
 *     a signature that ssh2's strict RFC verification rejects — see #1027.
 *   algorithmOverrides: per-category replacement lists (advanced). When a
 *     category's array is non-empty, it fully replaces the negotiated list
 *     for that category. Applied BEFORE skipEcdsaHostKey.
 */
function buildAlgorithms(legacyEnabled, options = {}) {
  const algorithms = buildBaseAlgorithms();

  if (legacyEnabled) {
    applyLegacyAlgorithms(algorithms);
    applyLegacyHmacAlgorithms(algorithms);
  }

  // User overrides apply BEFORE the ECDSA kill switch so the latter remains
  // unconditional ("never advertise ECDSA on this host").
  applyAlgorithmOverrides(algorithms, options.algorithmOverrides);

  if (options.skipEcdsaHostKey) {
    applyEcdsaHostKeySkip(algorithms);
  }

  return algorithms;
}

/**
 * Build SSH algorithm configuration for SFTP connections.
 * When legacyEnabled is true, legacy algorithms are appended for older device compatibility.
 *
 * @param {boolean} legacyEnabled
 * @param {{ skipEcdsaHostKey?: boolean }} [options]
 */
function buildSftpAlgorithms(legacyEnabled, options = {}) {
  const algorithms = buildBaseAlgorithms();

  if (legacyEnabled) {
    applyLegacyAlgorithms(algorithms);
    applyLegacyHmacAlgorithms(algorithms);
  }

  // User overrides apply BEFORE the ECDSA kill switch so the latter remains
  // unconditional ("never advertise ECDSA on this host").
  applyAlgorithmOverrides(algorithms, options.algorithmOverrides);

  if (options.skipEcdsaHostKey) {
    applyEcdsaHostKeySkip(algorithms);
  }

  return algorithms;
}

function _resetAlgorithmSupportCacheForTests() {
  _md5Supported = null;
  _supportedCiphers = null;
  dhGroupSupport.clear();
}

module.exports = {
  buildAlgorithms,
  buildSftpAlgorithms,
  _resetAlgorithmSupportCacheForTests,
};
