/**
 * @file Slot and port allocation for lane runtimes. A slot is the small integer
 * every per-lane runtime fact derives from — the numbering Shipyard gets for free
 * from its fixed `lane1..lane9` directories, and CCAM, whose lanes are keyed by
 * `cwd`, has to allocate. Ports come from `PORT_BASE_<name> + slot`, stepping
 * aside when something outside CCAM already holds the number. Database name,
 * Redis logical index, and upload directory are the same idea one layer up
 * (`dataFacts`) — every fact a lane's slot number determines, in one place.
 *
 * Allocation is the one runtime fact CCAM fully controls, which is why it lives
 * in the database (transactional, uniquely indexed) while liveness does not.
 * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
 */

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

const lanesLib = require("./lanes");
const { isListening, listenerPids } = require("./ports");
const { LANES_ROOT } = require("./worktree");

/**
 * How many lanes may hold a runtime at once.
 *
 * Nine by default, which is Shipyard's ceiling and worth keeping as a default:
 * a single decimal digit keeps `base + slot` readable (`:8003` is lane 3), and
 * Redis ships 16 logical databases, so A2's per-lane index stays in range. It is
 * configurable because CCAM, unlike Shipyard, has no structural reason to stop at
 * nine — a machine that can run twenty stacks may raise it, at the cost of ports
 * that no longer read as a slot number.
 *
 * Read per call so a test can change it without reloading the module.
 */
function maxSlots() {
  const raw = Number(process.env.LANE_MAX_SLOTS);
  return Number.isInteger(raw) && raw > 0 ? raw : 9;
}

/** How many `+100` steps to try before giving up on a port name. */
const PORT_STEP = 100;
const PORT_MAX_STEPS = 10;

/**
 * Claim the lowest free slot for a lane.
 *
 * Lowest-free rather than next-highest so a released slot is reused and the
 * numbers stay small and readable. MUST be called inside `withLaneLock` — the
 * read and the write are separated by nothing here, but the caller's subsequent
 * port resolution is async, and two provisions interleaving there would both
 * believe they own the number. The partial unique index on `lanes.slot` is the
 * backstop that turns a missed lock into a loud constraint error rather than two
 * lanes silently sharing a runtime.
 *
 * @param {number} laneId - Lane to assign the slot to.
 * @returns {number} The claimed slot.
 * @throws {Error} ESLOTS when every slot is taken.
 */
function allocateSlot(laneId) {
  const used = new Set(lanesLib.usedSlots());
  const limit = maxSlots();
  for (let slot = 1; slot <= limit; slot += 1) {
    if (used.has(slot)) continue;
    lanesLib.setProvisioningFacts(laneId, { slot });
    return slot;
  }
  throw Object.assign(new Error(`all ${limit} lane slots are in use`), { code: "ESLOTS" });
}

/**
 * Give a lane's slot and ports back to the pool.
 *
 * Called on `remove` only. A `reset` deliberately keeps them: Shipyard preserves
 * a lane's identity across resets, and moving a lane's ports (and, at A2, its
 * database) out from under a session that is mid-feature would be a silent,
 * confusing failure rather than a fresh start.
 *
 * @param {number} laneId - Lane to release.
 */
function releaseSlot(laneId) {
  lanesLib.setProvisioningFacts(laneId, { slot: null, ports: {} });
}

/**
 * The ports a lane should bind, by declared name.
 *
 * For each name the profile declares, prefer `PORT_BASE_<name> + slot`, then
 * `+100`, `+200`… The step keeps the last digit equal to the slot, so a
 * stepped-aside port still reads as "lane 3" — the property that makes the whole
 * `base + slot` scheme worth having.
 *
 * A number is rejected when anything is listening on it, when another lane has
 * recorded it (a lane whose stack is down still owns its number), or when an
 * earlier name in this same call already took it.
 *
 * Previously-recorded ports for THIS lane are reused as-is when still free, so a
 * lane that stepped aside once keeps the number its `.env`, bookmarks and any
 * seeded browser session already point at.
 *
 * @param {object} lane - Lane row; `slot` must already be allocated.
 * @param {object} profile - Resolved profile (supplies `ports` and `env`).
 * @returns {Promise<Record<string, number>>} Map of port name to port number.
 * @throws {Error} EPORTBUSY when a name exhausts its candidates.
 */
async function resolvePorts(lane, profile) {
  const reserved = lanesLib.reservedPorts(lane.id);
  const previous = lane.ports || {};
  const assigned = {};
  const takenHere = new Set();

  for (const name of profile.ports) {
    const base = portBase(profile, name);
    const candidates = [];
    // The number this lane used last time comes first: stability beats tidiness.
    if (Number.isInteger(previous[name])) candidates.push(previous[name]);
    for (let step = 0; step < PORT_MAX_STEPS; step += 1) {
      const candidate = base + step * PORT_STEP + lane.slot;
      if (!candidates.includes(candidate)) candidates.push(candidate);
    }

    let chosen = null;
    for (const candidate of candidates) {
      if (takenHere.has(candidate) || reserved.has(candidate)) continue;
      if (await isListening(candidate)) continue;
      chosen = candidate;
      break;
    }

    if (chosen === null) {
      const preferred = base + lane.slot;
      const pids = await listenerPids(preferred);
      throw Object.assign(
        new Error(
          `no free port for "${name}": tried ${candidates.join(", ")}` +
            (pids.length ? ` (${preferred} held by pid ${pids.join(", ")})` : "")
        ),
        { code: "EPORTBUSY", portName: name, preferred, pids }
      );
    }

    assigned[name] = chosen;
    takenHere.add(chosen);
  }

  return assigned;
}

/**
 * The configured base for a port name, defaulting to 8000 so a profile that adds
 * a service without declaring its base still gets a usable (if unsurprising)
 * number rather than NaN.
 */
function portBase(profile, name) {
  const raw = Number(profile.env[`PORT_BASE_${name}`]);
  return Number.isInteger(raw) ? raw : 8000;
}

/**
 * Where a lane's runtime bookkeeping lives: pid files, hook logs, the last boot
 * error.
 *
 * Under `LANES_ROOT/.state/`, deliberately OUTSIDE the worktree. `lane reset`
 * runs `git clean -fd`, which would sweep pid files out from under a running
 * stack and leave processes nobody can find to kill. `.state/` also sits outside
 * every path the three-check destroy guard reasons about, so runtime bookkeeping
 * can never be mistaken for a lane's working copy.
 *
 * @param {number} slot - Allocated slot.
 * @returns {{stateDir: string, runDir: string, logDir: string, errorFile: string}}
 */
function slotDirs(slot) {
  const stateDir = path.join(LANES_ROOT, ".state", `lane${slot}`);
  return {
    stateDir,
    runDir: path.join(stateDir, "run"),
    logDir: path.join(stateDir, "logs"),
    errorFile: path.join(stateDir, "last-error.json"),
  };
}

/** True when a lane's ports differ from `base + slot` — the UI flags this. */
function portsSteppedAside(lane, profile) {
  if (!lane.slot) return false;
  return profile.ports.some(
    (name) => lane.ports[name] && lane.ports[name] !== portBase(profile, name) + lane.slot
  );
}

/**
 * The database name a slot derives, or null when the profile has no
 * `DB_PREFIX` — the one gate every A2 data-isolation feature reads, so "off"
 * means no name is ever allocated rather than an empty-prefix name like `"3"`.
 *
 * @param {object} profile - Resolved profile.
 * @param {number} slot - Allocated slot.
 * @returns {string|null}
 */
function dbName(profile, slot) {
  return profile.env.DB_PREFIX ? `${profile.env.DB_PREFIX}${slot}` : null;
}

/**
 * Every slot-derived data-isolation fact a lane can have, in the one place
 * every other slot-derived fact (ports, directories) already lives. Each
 * field is null when its owning declaration is absent, so a caller can test
 * "is this feature on" with a single truthiness check instead of re-reading
 * profile.env itself.
 *
 * @param {object} lane - Lane row; `slot` must already be allocated.
 * @param {object} profile - Resolved profile.
 * @param {Record<string,string>} secrets - From `secrets.js:readSecrets()`.
 * @returns {{dbName: string|null, databaseUrl: string|null, testDatabaseUrl: string|null, redisUrl: string|null, uploadDir: string|null}}
 */
function dataFacts(lane, profile, secrets) {
  const name = dbName(profile, lane.slot);
  const scheme = profile.env.DB_URL_SCHEME || "postgresql";
  // encodeURIComponent on user/pass: a real password containing @, #, or %
  // would otherwise produce a URL the DB client parses wrong or rejects.
  const urlFor = (n) =>
    n
      ? `${scheme}://${encodeURIComponent(secrets.PG_USER)}:${encodeURIComponent(secrets.PG_PASS)}@${secrets.PG_HOST}:${secrets.PG_PORT}/${n}`
      : null;
  return {
    dbName: name,
    databaseUrl: urlFor(name),
    testDatabaseUrl: urlFor(name ? `${name}_test` : null),
    redisUrl:
      profile.env.REDIS === "1"
        ? `redis://${secrets.REDIS_HOST}:${secrets.REDIS_PORT}/${lane.slot}`
        : null,
    uploadDir: profile.env.UPLOAD_SUBDIR ? path.join(lane.cwd, profile.env.UPLOAD_SUBDIR) : null,
  };
}

module.exports = {
  allocateSlot,
  releaseSlot,
  resolvePorts,
  portBase,
  portsSteppedAside,
  slotDirs,
  dbName,
  dataFacts,
  maxSlots,
  PORT_STEP,
  PORT_MAX_STEPS,
};
