/**
 * @file The stack seam. A profile is a repository's own description of how to
 * build, boot and check its stack: `<repo>/.ccam/profile/` holding a `profile.env`
 * of declarations plus a `hooks/` directory of shell scripts. CCAM stays
 * stack-agnostic and calls those hooks with a stable environment contract, which
 * is the same contract Shipyard's `run_hook` exports so its profiles port over
 * unchanged.
 *
 * Two rules this module exists to enforce. Config is PARSED, never sourced —
 * sourcing arbitrary shell from a repository into the dashboard process would be
 * a code-execution path; hooks are executed deliberately, config is only read.
 * And a hook is always spawned as `bash <hook> <args…>` with a fixed argument
 * array, never a command string.
 * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
 */

const fs = require("node:fs");
const path = require("node:path");
const { spawn } = require("node:child_process");

const { slotDirs, dataFacts } = require("./lane-slots");

/** Directory, relative to a repository root, holding its profile. */
const PROFILE_SUBDIR = path.join(".ccam", "profile");

/**
 * Hook names CCAM will run, ever.
 *
 * A fixed allowlist rather than "whatever is in hooks/": `:name` arrives from an
 * HTTP route, and a name taken from a request is a path taken from a request.
 * `bootstrap`/`migrate`/`seed`/`ci-gate`/`e2e`/`regen` are not called by A1's
 * lifecycle but are listed here because `POST /:id/hook/:name` can run them on a
 * session's behalf, and the driving skill needs that surface stable.
 */
const HOOKS = Object.freeze([
  "bootstrap",
  "boot",
  "health",
  "migrate",
  "seed",
  "ci-gate",
  "e2e",
  "regen",
  "db-create",
  "db-drop",
]);

/**
 * Defaults for every declaration a profile may omit, so a missing key degrades
 * to something usable rather than breaking a lane. Mirrors the defaults block in
 * Shipyard's `_common.sh` so a ported profile behaves identically.
 */
const DEFAULTS = Object.freeze({
  PORTS: "api fe",
  PORT_BASE_api: "8000",
  PORT_BASE_fe: "3000",
  LANE_DIRS: "",
  BACKEND_DIR: "backend",
  FRONTEND_DIR: "frontend",
  API_PATH: "/api",
  // A2 data isolation — every one of these empty/0 is "feature off", so an
  // adopted repo that never declares them gets no per-lane database, no Redis
  // index, and no .env rewriting: dead code never runs rather than running on
  // guessed values.
  DB_PREFIX: "",
  DB_KIND: "",
  DB_URL_SCHEME: "postgresql",
  REDIS: "0",
  ENV_FILES: "",
  ENV_SOURCE: "",
  ENV_REWRITE: "",
  ENV_PRESERVE: "",
  UPLOAD_SUBDIR: "",
  // E1: space-separated KEY=value pairs injected into the BOOT hook's
  // environment only, only when `up` is called with qc:true — the deterministic
  // stack `ship-feature-lane`'s Stage 3 boots for QC. Empty = off, same as
  // every declaration above.
  QC_BOOT_ENV: "",
  // E2: repo-relative path to a numbered-migrations directory (e.g.
  // "db/migrations"), consumed by sync-base's collision preflight. Empty =
  // off, same DEFAULTS pattern as every declaration above.
  MIGRATIONS_DIR: "",
  // E2: space-separated repo-relative paths given a keep-ours merge driver
  // by sync-base's merge mode and regenerated post-merge by the profile's
  // `regen` hook (e.g. an OpenAPI contract + its generated client). Empty =
  // off — no driver installed, no regen fold-in attempted.
  GENERATED_MERGE_PATHS: "",
});

/**
 * Parse a `KEY=VALUE` declaration file.
 *
 * Not a shell parser and not trying to be: comments and blank lines are skipped,
 * an `export ` prefix is tolerated (profiles ported from Shipyard have it), and
 * one layer of matching quotes is stripped. Everything else is taken literally —
 * `$(id)`, backticks and `${VAR}` stay as written. That literalness IS the
 * security property; do not add expansion here.
 *
 * @param {string} text - File contents.
 * @returns {Record<string, string>}
 */
function parseEnvFile(text) {
  const out = {};
  for (const rawLine of text.split("\n")) {
    const line = rawLine.trim();
    if (!line || line.startsWith("#")) continue;
    const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line);
    if (!match) continue;
    let value = match[2].trim();
    if (
      value.length >= 2 &&
      ((value.startsWith('"') && value.endsWith('"')) ||
        (value.startsWith("'") && value.endsWith("'")))
    ) {
      value = value.slice(1, -1);
    }
    out[match[1]] = value;
  }
  return out;
}

/** Split a space-separated declaration into a deduplicated list. */
function splitList(value) {
  return [...new Set((value || "").split(/\s+/).filter(Boolean))];
}

/**
 * Parse a `QC_BOOT_ENV` declaration — space-separated `KEY=value` pairs — into
 * a plain object. A token with no `=` is dropped rather than throwing: a
 * malformed declaration should degrade to "that one pair is missing", not
 * crash a boot.
 *
 * @param {string} [value]
 * @returns {Record<string,string>}
 */
function parseQcBootEnv(value) {
  const out = {};
  for (const token of splitList(value)) {
    const eq = token.indexOf("=");
    if (eq <= 0) continue;
    out[token.slice(0, eq)] = token.slice(eq + 1);
  }
  return out;
}

/**
 * Find and read a lane's profile.
 *
 * The lane's OWN working copy is searched first, its source repository second.
 * The profile lives in the repository, so a worktree already carries the version
 * belonging to its branch — and a branch that changes a boot command must boot
 * with the command it changed, not the one on the base branch. The source-repo
 * fallback covers a profile the user keeps gitignored, which never reaches a
 * worktree through git.
 *
 * @param {object} lane - Lane row (`cwd`, `source_repo`).
 * @returns {{dir: string, env: Record<string,string>, hooks: Set<string>, ports: string[], laneDirs: string[]}|null}
 *          null when neither location has a profile — a normal state, not a fault.
 */
function resolveProfile(lane) {
  const candidates = [lane.cwd, lane.source_repo].filter(Boolean);
  for (const root of candidates) {
    const dir = path.join(root, PROFILE_SUBDIR);
    if (!fs.existsSync(path.join(dir, "profile.env"))) continue;

    let declared = {};
    try {
      declared = parseEnvFile(fs.readFileSync(path.join(dir, "profile.env"), "utf8"));
    } catch {
      continue; // unreadable profile is the same as no profile
    }
    const env = { ...DEFAULTS, ...declared };

    const hooks = new Set();
    for (const name of HOOKS) {
      if (fs.existsSync(path.join(dir, "hooks", `${name}.sh`))) hooks.add(name);
    }

    return {
      dir,
      env,
      hooks,
      ports: splitList(env.PORTS),
      laneDirs: splitList(env.LANE_DIRS),
      generatedMergePaths: splitList(env.GENERATED_MERGE_PATHS),
    };
  }
  return null;
}

/** The profile paths searched for a lane, for an ENOPROFILE message. */
function profileSearchPaths(lane) {
  return [lane.cwd, lane.source_repo]
    .filter(Boolean)
    .map((root) => path.join(root, PROFILE_SUBDIR));
}

/**
 * Whether a named integration is turned on for this lane — reads
 * .ccam/profile/integrations.env (same two-location search as profile.env:
 * the lane's own working copy first, the source repo second) and checks
 * <NAME>_ENABLED=1. A missing file or missing key is off, never an error —
 * same off-by-default shape every other optional declaration follows.
 *
 * @param {object} lane - Lane row (`cwd`, `source_repo`).
 * @param {string} name - Lowercase integration name, e.g. "tracker", "dev_qc", "ci_wait".
 * @returns {boolean}
 */
function isIntegrationEnabled(lane, name) {
  const candidates = [lane.cwd, lane.source_repo].filter(Boolean);
  const key = `${name.toUpperCase()}_ENABLED`;
  for (const root of candidates) {
    const filePath = path.join(root, PROFILE_SUBDIR, "integrations.env");
    if (!fs.existsSync(filePath)) continue;
    let declared;
    try {
      declared = parseEnvFile(fs.readFileSync(filePath, "utf8"));
    } catch {
      continue;
    }
    return declared[key] === "1";
  }
  return false;
}

/**
 * A `harness_spawn` shell function, injected into every hook.
 *
 * Same name and signature as Shipyard's so ported hooks work untouched:
 * `harness_spawn <name> <workdir> <cmd…>` backgrounds a long-lived service and
 * records its pid where downLane looks.
 *
 * The stdio detachment is not optional. A child that inherits the caller's stdout
 * holds that pipe open, so a caller reading to EOF never returns — the failure
 * that stalled Shipyard's boot stage until it was fixed the same way. `nohup` plus
 * a closed stdin plus redirected output is what lets a lane's stack outlive both
 * the hook and the dashboard.
 */
const HARNESS_SPAWN = `
harness_spawn() {
  local name="$1" wd="$2"; shift 2
  ( cd "$wd" || exit 1
    nohup "$@" >"$LOG_DIR/$name.log" 2>&1 </dev/null &
    echo $! >"$RUN_DIR/$name.pid"
  ) </dev/null >/dev/null 2>&1
}
die() { echo "profile: $*" >&2; exit 1; }
`;

/**
 * The environment contract every hook can rely on.
 *
 * Deliberately identical to Shipyard's `run_hook` exports where the concept
 * survives the port, so a profile written for the harness runs here unchanged.
 * `LANE` is the SLOT, not the lane id — Shipyard hooks use it to derive per-lane
 * names, and the slot is what carries that meaning.
 *
 * Git variables are scrubbed for the same reason `worktree.js:git()` scrubs them:
 * a hook that shells out to git must not inherit a git context pointing at the
 * dashboard's own repository, and `GIT_CONFIG_*` can inject `core.hooksPath` into
 * every git call the hook makes.
 *
 * `require("./secrets")` is deferred to the function body rather than hoisted
 * to the top of the file: `secrets.js` itself requires this module for
 * `parseEnvFile`, and a top-level require here would complete the cycle while
 * this file's own `module.exports` is still empty, handing `secrets.js` an
 * `undefined` parser. Deferring past module-load time breaks the cycle.
 */
function hookEnv(lane, profile, extraEnv = {}) {
  const { readSecrets } = require("./secrets");
  const dirs = slotDirs(lane.slot);
  const secrets = readSecrets();
  const facts = dataFacts(lane, profile, secrets);
  const env = { ...process.env };

  delete env.GIT_DIR;
  delete env.GIT_WORK_TREE;
  delete env.GIT_INDEX_FILE;
  delete env.GIT_COMMON_DIR;
  delete env.GIT_OBJECT_DIRECTORY;
  delete env.GIT_ALTERNATE_OBJECT_DIRECTORIES;
  delete env.GIT_PREFIX;
  delete env.GIT_NAMESPACE;
  delete env.GIT_CONFIG_PARAMETERS;
  for (const name of Object.keys(env)) {
    if (/^GIT_CONFIG_(COUNT|KEY_\d+|VALUE_\d+|GLOBAL|SYSTEM)$/.test(name)) delete env[name];
  }
  env.GIT_TERMINAL_PROMPT = "0";

  Object.assign(env, profile.env, {
    LANE: String(lane.slot),
    LANE_ID: String(lane.id),
    LANE_DIR: lane.cwd,
    SOURCE_REPO: lane.source_repo || lane.cwd,
    PROFILE_DIR: profile.dir,
    RUN_DIR: dirs.runDir,
    LOG_DIR: dirs.logDir,
  });

  // A2 data-isolation facts, present only when their owning declaration is —
  // a profile with no DB_PREFIX sees no DB_NAME/DATABASE_URL at all, so a
  // db-create.sh hook that forgot to check DB_PREFIX fails loudly (unset var
  // under `set -u`) instead of quietly touching a database named "undefined".
  if (facts.dbName) {
    env.DB_NAME = facts.dbName;
    env.DATABASE_URL = facts.databaseUrl;
    env.TEST_DATABASE_URL = facts.testDatabaseUrl;
    // Raw connection settings, not just the assembled URL: Shipyard's own
    // db-create/db-drop hooks call `createdb -U "$PG_USER"` directly (trust
    // auth inside the compose network, no password needed), and a ported
    // profile expects these names verbatim. PG_PASS is deliberately withheld —
    // nothing in the ported hooks needs it, and every value that DOES reach a
    // hook's environment is a value that could end up in an echoed debug line.
    env.PG_HOST = secrets.PG_HOST;
    env.PG_PORT = secrets.PG_PORT;
    env.PG_USER = secrets.PG_USER;
  }
  if (facts.redisUrl) {
    env.REDIS_URL = facts.redisUrl;
    env.REDIS_HOST = secrets.REDIS_HOST;
    env.REDIS_PORT = secrets.REDIS_PORT;
  }
  if (facts.uploadDir) env.UPLOAD_DIR = facts.uploadDir;

  // <NAME>_PORT for every declared port, upper-cased: PORTS="api fe" -> API_PORT, FE_PORT.
  for (const [name, port] of Object.entries(lane.ports || {})) {
    env[`${name.toUpperCase()}_PORT`] = String(port);
  }

  Object.assign(env, extraEnv);
  return env;
}

/**
 * Run one of the profile's hooks.
 *
 * Spawned through a one-line bash wrapper that defines the shell helpers,
 * `export -f`s them (exactly as Shipyard's `run_hook` does) and then `exec`s the
 * hook, so the hook runs as its own script with its own `set -e` while still
 * seeing `harness_spawn`. The hook path and its arguments travel as an argument
 * ARRAY appended after the wrapper — never interpolated into the script text, so
 * a lane directory containing a quote or a space is a path, not a command.
 *
 * Output is streamed line by line to `onLine` (the caller broadcasts it) and
 * appended to `$LOG_DIR/<name>.log`, so a boot is watchable live and readable
 * afterwards. Any value that came from `secrets.js` (currently `PG_PASS`, and
 * therefore the password segment of `DATABASE_URL`/`TEST_DATABASE_URL`) is
 * redacted from that stream first: `runHook`'s output reaches a browser tab
 * over the `lane_hook_output` websocket, and a hook that echoes its own
 * environment (common while debugging a failing migration) must not publish a
 * database password to everyone watching.
 *
 * @param {object} lane - Lane row with an allocated slot and resolved ports.
 * @param {object} profile - From resolveProfile().
 * @param {string} name - Hook name; must be in HOOKS.
 * @param {string[]} [args] - Extra arguments passed to the hook.
 * @param {{onLine?: (line: string, stream: "stdout"|"stderr") => void, timeoutMs?: number}} [options]
 * @returns {Promise<{code: number, output: string}>} Resolves even on a non-zero exit.
 */
function runHook(lane, profile, name, args = [], options = {}) {
  if (!HOOKS.includes(name)) {
    return Promise.reject(
      Object.assign(new Error(`unknown hook: ${name}`), { code: "ENOHOOK", hook: name })
    );
  }
  const hookPath = path.join(profile.dir, "hooks", `${name}.sh`);
  if (!fs.existsSync(hookPath)) {
    return Promise.reject(
      Object.assign(new Error(`profile has no hook "${name}.sh"`), {
        code: "ENOHOOK",
        hook: name,
        path: hookPath,
      })
    );
  }

  const dirs = slotDirs(lane.slot);
  fs.mkdirSync(dirs.runDir, { recursive: true });
  fs.mkdirSync(dirs.logDir, { recursive: true });
  const logPath = path.join(dirs.logDir, `${name}.log`);
  const logStream = fs.createWriteStream(logPath, { flags: "a" });

  // Deferred for the same reason as inside hookEnv(): secrets.js requires this
  // module, so a top-level require here would complete the load cycle early.
  // Only the password is redacted — host/port/user are not secret on their
  // own, and treating them as such would mangle unrelated numbers in output.
  // Both forms: DATABASE_URL embeds the URL-encoded password, so a raw echo
  // of the password and an echo of DATABASE_URL need separate substrings.
  const pgPass = require("./secrets").readSecrets().PG_PASS;
  const secretValues = [pgPass, pgPass && encodeURIComponent(pgPass)].filter(Boolean);
  const redact = (text) => secretValues.reduce((s, v) => s.split(v).join("[REDACTED]"), text);

  return new Promise((resolve, reject) => {
    const child = spawn(
      "bash",
      [
        "-c",
        `${HARNESS_SPAWN}\nexport -f harness_spawn die\nexec bash "$@"`,
        "bash",
        hookPath,
        ...args.map(String),
      ],
      {
        cwd: lane.cwd,
        env: hookEnv(lane, profile, options.extraEnv),
        stdio: ["ignore", "pipe", "pipe"],
      }
    );

    let output = "";
    let settled = false;
    const timer = options.timeoutMs
      ? setTimeout(() => {
          child.kill("SIGKILL");
        }, options.timeoutMs)
      : null;

    const consume = (stream, which) => {
      let buffer = "";
      stream.setEncoding("utf8");
      stream.on("data", (raw) => {
        const chunk = secretValues.length ? redact(raw) : raw;
        output += chunk;
        logStream.write(chunk);
        buffer += chunk;
        const lines = buffer.split("\n");
        buffer = lines.pop();
        for (const line of lines) options.onLine?.(line, which);
      });
      stream.on("end", () => {
        if (buffer) options.onLine?.(buffer, which);
      });
    };
    consume(child.stdout, "stdout");
    consume(child.stderr, "stderr");

    const finish = (fn, value) => {
      if (settled) return;
      settled = true;
      if (timer) clearTimeout(timer);
      logStream.end();
      fn(value);
    };

    child.on("error", (err) => finish(reject, err));
    child.on("close", (code) => finish(resolve, { code: code ?? 1, output, logPath }));
  });
}

module.exports = {
  HOOKS,
  DEFAULTS,
  PROFILE_SUBDIR,
  parseEnvFile,
  splitList,
  parseQcBootEnv,
  resolveProfile,
  profileSearchPaths,
  isIntegrationEnabled,
  hookEnv,
  runHook,
};
