/**
 * @file Node.js project detection and `.ccam/profile/` scaffolding (A3).
 * `detectNode` reads a repository's filesystem (package.json layout,
 * docker-compose.yml) without executing anything; `scaffoldProfile` turns
 * those facts into a working profile; `checkProfile` validates one. Only a
 * single preset (Node.js, single-service or a flat backend/+frontend/
 * monorepo) is detected — anything else is refused rather than guessed, per
 * `docs/superpowers/specs/2026-08-03-lane-profile-scaffolding-design.md`.
 * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
 */

const fs = require("node:fs");
const path = require("node:path");
const yaml = require("js-yaml");
const { parseEnvFile } = require("./lane-profile");
const { isListening } = require("./ports");
const { SECRETS_PATH } = require("./secrets");

/**
 * A name safe to embed as literal text inside a generated shell script (an
 * npm script name, a docker-compose service name). Deliberately NOT applied
 * to profile.env `KEY=VALUE` values — those are parsed, never sourced, and
 * already safe by construction (see `lane-profile.js:parseEnvFile`). This
 * guard is only for names that get spliced into `.sh` file TEXT.
 */
const IDENTIFIER_RE = /^[\w.:-]+$/;

/** Read and parse a package.json, or null if it doesn't exist or is invalid JSON. */
function readPackageJson(dir) {
  try {
    return JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
  } catch {
    return null;
  }
}

/**
 * First candidate script name present in `pkg.scripts` AND safe to embed in
 * shell text. Returns null (never a guess) when no candidate qualifies.
 *
 * @param {object|null} pkg - Parsed package.json.
 * @param {string[]} candidates - Script names in preference order.
 * @returns {string|null}
 */
function pickScript(pkg, candidates) {
  if (!pkg || typeof pkg.scripts !== "object" || !pkg.scripts) return null;
  for (const name of candidates) {
    if (typeof pkg.scripts[name] === "string" && IDENTIFIER_RE.test(name)) return name;
  }
  return null;
}

/**
 * A repo-name-derived DB_PREFIX: lowercase alnum + underscore, "_l" suffix
 * (lane N's actual name becomes "<prefix>N"), matching the convention in
 * the postgres-compose profile template's example.
 *
 * @param {string} repoPath - Absolute path to the repository root.
 * @returns {string}
 */
function dbPrefixFor(repoPath) {
  const base = path
    .basename(repoPath)
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "_")
    .replace(/^_+|_+$/g, "");
  return `${base || "app"}_l`;
}

/**
 * The first docker-compose service name matching `pattern`, or null. Reads
 * `docker-compose.yml`/`.yaml` at the repo root; never executes it.
 *
 * @param {string} repoPath - Absolute path to the repository root.
 * @param {RegExp} pattern - Pattern to match service name.
 * @returns {string|null}
 */
function findComposeService(repoPath, pattern) {
  for (const name of ["docker-compose.yml", "docker-compose.yaml"]) {
    let doc;
    try {
      doc = yaml.load(fs.readFileSync(path.join(repoPath, name), "utf8"));
    } catch {
      continue;
    }
    const services = doc && typeof doc.services === "object" ? doc.services : {};
    for (const serviceName of Object.keys(services)) {
      if (pattern.test(serviceName) && IDENTIFIER_RE.test(serviceName)) return serviceName;
    }
  }
  return null;
}

/**
 * Where a lane's .env lives for this layout: backend/.env in a monorepo,
 * .env at the root for single-service. Returns the source to seed FROM
 * (real .env, else .env.example) or null when neither exists.
 *
 * @param {string} repoPath - Absolute path to the repository root.
 * @param {string} envDir - Directory where .env lives (e.g. "backend" or ".").
 * @returns {object|null}
 */
function findEnvSource(repoPath, envDir) {
  const dir = path.join(repoPath, envDir);
  const real = path.join(dir, ".env");
  const example = path.join(dir, ".env.example");
  const relFile = path.join(envDir, ".env").replace(/\\/g, "/").replace(/^\.\//, "");
  if (fs.existsSync(real)) return { file: relFile, source: relFile, fromExample: false };
  if (fs.existsSync(example)) {
    return { file: relFile, source: `${relFile}.example`, fromExample: true };
  }
  return null;
}

/**
 * Detect database and Redis services from docker-compose.yml and wire up
 * .env rewriting if services are found.
 *
 * @param {string} repoPath - Absolute path to the repository root.
 * @param {string} envDir - Directory where .env lives (e.g. "backend" or ".").
 * @returns {object}
 */
function detectDatabaseAndEnv(repoPath, envDir) {
  const dbService = findComposeService(repoPath, /postgres/i);
  const redis = findComposeService(repoPath, /redis/i) !== null;
  const database = dbService
    ? { dbPrefix: dbPrefixFor(repoPath), dbService, dbKind: "postgres" }
    : null;

  let env = null;
  if (database || redis) {
    const found = findEnvSource(repoPath, envDir);
    if (found) {
      const rewrite = [];
      if (database) rewrite.push("DATABASE_URL");
      if (redis) rewrite.push("REDIS_URL");
      env = { dir: envDir, ...found, rewrite };
    }
  }

  return { database, redis, env };
}

/**
 * Detect a Node.js project's layout and, for each service, which npm script
 * to run. Returns null when neither supported layout matches — the caller
 * must refuse rather than guess.
 *
 * @param {string} repoPath - Absolute path to the repository root.
 * @returns {object|null}
 */
function detectNode(repoPath) {
  const backendPkg = readPackageJson(path.join(repoPath, "backend"));
  const frontendPkg = readPackageJson(path.join(repoPath, "frontend"));

  if (backendPkg && frontendPkg) {
    const { database, redis, env } = detectDatabaseAndEnv(repoPath, "backend");
    return {
      layout: "monorepo",
      services: {
        backend: { dir: "backend", script: pickScript(backendPkg, ["start", "dev"]) },
        frontend: { dir: "frontend", script: pickScript(frontendPkg, ["preview", "dev"]) },
      },
      database,
      redis,
      env,
    };
  }

  const rootPkg = readPackageJson(repoPath);
  if (rootPkg) {
    const { database, redis, env } = detectDatabaseAndEnv(repoPath, ".");
    return {
      layout: "single-service",
      services: { app: { dir: ".", script: pickScript(rootPkg, ["start", "dev"]) } },
      database,
      redis,
      env,
    };
  }

  return null;
}

const TEMPLATE_DB_HOOKS_DIR = path.join(
  __dirname,
  "..",
  "data",
  "profile-templates",
  "postgres-compose",
  "hooks"
);

function writeHook(profileDir, name, body) {
  const file = path.join(profileDir, "hooks", `${name}.sh`);
  fs.writeFileSync(file, body);
  fs.chmodSync(file, 0o755);
}

/**
 * A single service's boot.sh line: a real harness_spawn call, or a TODO
 * comment (never a guessed command) when no script was found.
 *
 * Passes the port via a `PORT` environment variable, not a `--port` argv
 * flag: framework CLI flags for the port genuinely differ (`-p` for Next.js,
 * `--port` for Vite, nothing at all for a plain Express app), but reading
 * `process.env.PORT` is the one convention broad enough to default to. It
 * will not work for every framework — same disclaimer as the hardcoded
 * `PORT_BASE_*` values themselves — but it's the safest single default.
 */
function bootLine(name, service) {
  if (!service.script) {
    return `# TODO: no start/dev script found in ${service.dir}/package.json — edit this line`;
  }
  const portVar = `${name.toUpperCase()}_PORT`;
  const workdir = service.dir === "." ? '"$LANE_DIR"' : `"$LANE_DIR/${service.dir}"`;
  return `harness_spawn ${name} ${workdir} env PORT="$${portVar}" npm run ${service.script}`;
}

/**
 * Scaffold `.ccam/profile/` under `repoPath` from `detectNode`'s facts.
 * Refuses to overwrite an existing `profile.env` unless `options.force`.
 *
 * @param {string} repoPath - Absolute repository path.
 * @param {object} facts - From `detectNode()`.
 * @param {{force?: boolean}} [options]
 * @returns {{written: string[], todos: string[]}}
 */
function scaffoldProfile(repoPath, facts, options = {}) {
  const profileDir = path.join(repoPath, ".ccam", "profile");
  const envPath = path.join(profileDir, "profile.env");
  if (fs.existsSync(envPath) && !options.force) {
    throw Object.assign(new Error(`profile already exists at ${envPath}`), {
      code: "EPROFILEEXISTS",
    });
  }
  fs.mkdirSync(path.join(profileDir, "hooks"), { recursive: true });

  const written = [];
  const todos = [];
  const lines = [];
  const bootLines = [];

  if (facts.layout === "monorepo") {
    lines.push('PORTS="api fe"', "PORT_BASE_api=8000", "PORT_BASE_fe=3000");
    bootLines.push(bootLine("api", facts.services.backend));
    bootLines.push(bootLine("fe", facts.services.frontend));
  } else {
    lines.push('PORTS="app"', "PORT_BASE_app=3000");
    bootLines.push(bootLine("app", facts.services.app));
  }
  for (const line of bootLines) if (line.startsWith("# TODO")) todos.push(line);

  if (facts.database) {
    lines.push(
      `DB_PREFIX=${facts.database.dbPrefix}`,
      "DB_KIND=postgres",
      "DB_URL_SCHEME=postgresql",
      "COMPOSE_FILE=docker-compose.yml",
      `DB_SERVICE=${facts.database.dbService}`
    );
  }
  if (facts.redis) lines.push("REDIS=1");
  if (facts.env) {
    lines.push(
      `ENV_FILES="${facts.env.file}"`,
      `ENV_SOURCE="${facts.env.source}"`,
      `ENV_REWRITE="${facts.env.rewrite.join(" ")}"`
    );
  }

  fs.writeFileSync(envPath, `${lines.join("\n")}\n`);
  written.push("profile.env");

  writeHook(
    profileDir,
    "bootstrap",
    facts.layout === "monorepo"
      ? '#!/usr/bin/env bash\nset -euo pipefail\nnpm install --prefix "$LANE_DIR/backend"\nnpm install --prefix "$LANE_DIR/frontend"\n'
      : '#!/usr/bin/env bash\nset -euo pipefail\nnpm install --prefix "$LANE_DIR"\n'
  );
  writeHook(
    profileDir,
    "boot",
    `#!/usr/bin/env bash\nset -euo pipefail\n${bootLines.join("\n")}\n`
  );
  const healthPortVar = facts.layout === "monorepo" ? "FE_PORT" : "APP_PORT";
  writeHook(
    profileDir,
    "health",
    `#!/usr/bin/env bash\nset -euo pipefail\ncurl -sf --retry 30 --retry-delay 1 --retry-all-errors "http://127.0.0.1:$${healthPortVar}/" >/dev/null\n`
  );
  written.push("hooks/bootstrap.sh", "hooks/boot.sh", "hooks/health.sh");

  if (facts.database) {
    for (const name of ["db-create", "db-drop"]) {
      fs.copyFileSync(
        path.join(TEMPLATE_DB_HOOKS_DIR, `${name}.sh`),
        path.join(profileDir, "hooks", `${name}.sh`)
      );
      fs.chmodSync(path.join(profileDir, "hooks", `${name}.sh`), 0o755);
      written.push(`hooks/${name}.sh`);
    }
    const todoBody = (label) =>
      `#!/usr/bin/env bash\necho "TODO: no ${label} tool detected — add your ${label} command here"\nexit 0\n`;
    writeHook(profileDir, "migrate", todoBody("migration"));
    writeHook(profileDir, "seed", todoBody("seed"));
    written.push("hooks/migrate.sh", "hooks/seed.sh");
    todos.push("hooks/migrate.sh", "hooks/seed.sh");
  }

  return { written, todos };
}

/** Every hook name a scaffolded Node profile might reference, given what
 *  profile.env declares — mirrors the same "only what's declared" gating
 *  scaffoldProfile itself uses. */
function referencedHooks(env) {
  const hooks = ["bootstrap", "boot", "health"];
  if (env.DB_PREFIX) hooks.push("db-create", "db-drop", "migrate", "seed");
  return hooks;
}

function containsTodo(text) {
  return text.includes("TODO:");
}

/**
 * Validate a scaffolded (or hand-written) profile. Read-only: never mutates
 * anything, never spawns a hook. `dir` may be a repo root (profile at
 * `.ccam/profile/`) or a `.ccam/profile/` directory itself.
 *
 * @param {string} dir
 * @returns {Promise<{ok: boolean, errors: string[], warnings: string[]}>}
 */
async function checkProfile(dir) {
  const errors = [];
  const warnings = [];

  const candidates = [path.join(dir, ".ccam", "profile"), dir];
  const profileDir = candidates.find((p) => fs.existsSync(path.join(p, "profile.env")));
  if (!profileDir) {
    return { ok: false, errors: [`no profile.env found under ${dir}`], warnings: [] };
  }

  const envPath = path.join(profileDir, "profile.env");
  const envText = fs.readFileSync(envPath, "utf8");
  let env;
  try {
    env = parseEnvFile(envText);
  } catch (err) {
    return { ok: false, errors: [`profile.env does not parse: ${err.message}`], warnings: [] };
  }
  if (containsTodo(envText)) errors.push("profile.env still has an unresolved TODO");

  for (const name of referencedHooks(env)) {
    const hookPath = path.join(profileDir, "hooks", `${name}.sh`);
    if (!fs.existsSync(hookPath)) {
      errors.push(`missing hook: ${name}.sh`);
      continue;
    }
    if (!(fs.statSync(hookPath).mode & 0o111)) errors.push(`hook not executable: ${name}.sh`);
    let body;
    try {
      body = fs.readFileSync(hookPath, "utf8");
    } catch (err) {
      errors.push(`${name}.sh is not readable`);
      continue;
    }
    if (containsTodo(body)) errors.push(`${name}.sh still has an unresolved TODO`);
  }

  if (env.DB_PREFIX && !fs.existsSync(SECRETS_PATH)) {
    warnings.push(`no secrets.env at ${SECRETS_PATH} — database will use built-in defaults`);
  }

  for (const key of Object.keys(env)) {
    const match = /^PORT_BASE_(.+)$/.exec(key);
    if (!match) continue;
    const port = Number(env[key]);
    if (Number.isInteger(port) && (await isListening(port))) {
      errors.push(`port ${port} (PORT_BASE_${match[1]}) is already in use`);
    }
  }

  return { ok: errors.length === 0, errors, warnings };
}

module.exports = {
  IDENTIFIER_RE,
  readPackageJson,
  pickScript,
  dbPrefixFor,
  findComposeService,
  findEnvSource,
  detectNode,
  scaffoldProfile,
  checkProfile,
};
