/**
 * @file TCP port probing for lane runtime allocation. Two questions the runtime
 * layer needs answered about a local port: is anything listening on it, and if so
 * which processes. `isListening` decides whether a lane's preferred port is free
 * and whether its stack actually came up; `listenerPids` names the occupier in an
 * EPORTBUSY error and backs up `downLane`'s pid-tree kill when a detached child
 * outlives its recorded parent.
 *
 * Deliberately has no opinion about lanes — it takes a port number and returns a
 * fact, so it can be tested against a throwaway server with no database.
 * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
 */

const net = require("node:net");
const { execFile } = require("node:child_process");
const { promisify } = require("node:util");

const execFileAsync = promisify(execFile);

/** How long to wait for a connect before calling the port free. */
const PROBE_TIMEOUT_MS = Number(process.env.LANE_PORT_PROBE_MS) || 300;

/**
 * Is something accepting TCP connections on this port?
 *
 * Connect-based rather than bind-based on purpose: binding to test a port races
 * with the thing we are about to start (we would have to release the socket
 * before the hook binds it, and a sibling lane could take it in between), and on
 * some platforms a successful bind says nothing about a listener already held by
 * another user. A refused connection is unambiguous — nobody is serving there.
 *
 * A timeout counts as "listening": a port that accepts the TCP handshake but
 * never responds is occupied, and treating it as free would hand a lane a port it
 * cannot bind.
 *
 * @param {number} port - TCP port on the loopback interface.
 * @returns {Promise<boolean>}
 */
function isListening(port) {
  return new Promise((resolve) => {
    const socket = new net.Socket();
    let settled = false;
    const done = (result) => {
      if (settled) return;
      settled = true;
      socket.destroy();
      resolve(result);
    };
    socket.setTimeout(PROBE_TIMEOUT_MS);
    socket.once("connect", () => done(true));
    socket.once("timeout", () => done(true));
    socket.once("error", () => done(false));
    socket.connect(port, "127.0.0.1");
  });
}

/**
 * Warned once per process when neither `lsof` nor `ss` exists, so a container or
 * a minimal image does not print the same line on every probe.
 */
let warnedNoTool = false;

/** Parse a newline-separated list of pids, dropping anything non-numeric. */
function parsePidList(stdout) {
  return [
    ...new Set(
      stdout
        .split("\n")
        .map((line) => line.trim())
        .filter((line) => /^\d+$/.test(line))
        .map(Number)
    ),
  ];
}

/**
 * Which processes are listening on this port.
 *
 * Best-effort by design: this only ever enriches an error message or adds a
 * backstop to a kill that has already been attempted through the recorded pid
 * files. A missing tool must never fail a lane operation, so every failure path
 * returns an empty array rather than throwing.
 *
 * `lsof` first (present on macOS and most Linux installs), then `ss` from
 * iproute2 (present on minimal Linux images where lsof is not).
 *
 * @param {number} port - TCP port on the loopback interface.
 * @returns {Promise<number[]>} Listening pids, or [] when unknown.
 */
async function listenerPids(port) {
  try {
    const { stdout } = await execFileAsync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"]);
    return parsePidList(stdout);
  } catch (err) {
    // lsof exits 1 when nothing matches — that is an answer, not a missing tool.
    if (err.code === 1) return [];
  }

  try {
    const { stdout } = await execFileAsync("ss", ["-lptnH", `sport = :${port}`]);
    return [...new Set([...stdout.matchAll(/pid=(\d+)/g)].map((m) => Number(m[1])))];
  } catch {
    /* fall through to the warning */
  }

  if (!warnedNoTool) {
    warnedNoTool = true;
    console.warn(
      "[ports] neither lsof nor ss is available — port occupants cannot be named, " +
        "and lane down falls back to the recorded pid files alone"
    );
  }
  return [];
}

module.exports = { isListening, listenerPids, PROBE_TIMEOUT_MS };
