// _runtimePaths.js
// Shared path resolution for evolver hook scripts.
//
// Two responsibilities:
//   1. Locate the evolver package root, supporting:
//      - $EVOLVER_ROOT explicit override
//      - The "scripts colocated with src" layout used during dev (../../..)
//      - The npm-global install layout, where the hook script lives under
//        `<prefix>/lib/node_modules/<host>/.../hooks/` and `..` walks lead
//        somewhere outside the evolver package. We resolve via
//        `require.resolve('@evomap/evolver/package.json')` instead.
//      - The `~/skills/evolver` fallback (some users symlink there).
//
//   2. Locate (or pick a writable default for) the evolution memory graph,
//      so that hook scripts in environments without an evolver-managed
//      project directory still record outcomes somewhere instead of
//      reporting "nowhere (no Hub or local path)" (#536).

const fs = require('fs');
const path = require('path');
const os = require('os');
const { spawnSync } = require('child_process');

function isEvolverPackageJson(filePath) {
  try {
    const raw = fs.readFileSync(filePath, 'utf8');
    const pkg = JSON.parse(raw);
    return pkg && (pkg.name === '@evomap/evolver' || pkg.name === 'evolver');
  } catch {
    return false;
  }
}

// Scan a "versions dir" used by Node version managers (NVM, fnm, Volta, asdf)
// and append each `<versions-dir>/<version>/<subdir>/node_modules` to `out`.
// Skips silently when the versions dir does not exist (typical case — most
// users have at most one version manager). Most-recent-mtime first so the
// active version is preferred when a user has multiple Node versions
// installed.
function _scanVersionedNodeModules(versionsDir, subdir, out) {
  let entries;
  try {
    entries = fs.readdirSync(versionsDir, { withFileTypes: true });
  } catch {
    return;
  }
  const dirs = entries
    .filter((e) => e.isDirectory && e.isDirectory())
    .map((e) => {
      const full = path.join(versionsDir, e.name);
      let mtime = 0;
      try { mtime = fs.statSync(full).mtimeMs; } catch {}
      return { full, mtime };
    })
    .sort((a, b) => b.mtime - a.mtime);
  for (const d of dirs) {
    out.push(path.join(d.full, subdir, 'node_modules'));
  }
}

// Build the require.resolve paths array. All entries are user/system-scoped
// install roots — process.cwd() is intentionally excluded for the same
// prompt-injection reason as the original allowlist (see comment in
// findEvolverRoot below).
function _buildInstallSearchPaths() {
  const home = os.homedir();
  // Env-derived bases must be ABSOLUTE. A relative override (e.g. NVM_DIR='.nvm')
  // or empty value would resolve against process.cwd() and let a hostile
  // workspace plant a fake @evomap/evolver in the require.resolve allowlist —
  // the prompt-injection surface PR #94 closed. isAbsolute is platform-matched
  // (path.win32 recognises C:\ ...) so the guard holds on Windows too; a
  // non-absolute override falls through to the trusted home/system default.
  const _pathFlavor = process.platform === 'win32' ? path.win32 : path.posix;
  const absEnv = (v) => (v && _pathFlavor.isAbsolute(v)) ? v : null;
  const paths = [
    // npm global with `npm config set prefix` overrides
    path.join(home, '.npm-global', 'lib', 'node_modules'),
    path.join(home, '.local', 'lib', 'node_modules'),
    // System-wide (apt/yum nodejs, Intel Mac Homebrew)
    '/usr/lib/node_modules',
    '/usr/local/lib/node_modules',
    // Apple Silicon Homebrew (default since macOS Big Sur on M1/M2/M3/M4 —
    // the majority of Mac dev hardware sold since 2021). Without this
    // entry, `npm install -g @evomap/evolver` on an Apple Silicon Mac
    // lands at /opt/homebrew/lib/node_modules/@evomap/evolver and the
    // hook scripts cannot find the package -> additionalContext is empty
    // -> evolution memory never reaches the LLM.
    '/opt/homebrew/lib/node_modules',
    // Linuxbrew (Homebrew on Linux — niche but real).
    '/home/linuxbrew/.linuxbrew/lib/node_modules',
  ];
  // Per-user Node version managers. Each manager has its own on-disk layout
  // and its own base-dir env override; the version subdirectory is dynamic
  // (e.g. `~/.nvm/versions/node/v22.15.0`) so we scan and append each
  // version's node_modules. These were missing from the original hard-coded
  // list even though NVM in particular is extremely common across all OSes.

  // NVM. Globals are per-version under `<NVM_DIR>/versions/node/<ver>/lib`.
  // NVM_DIR defaults to ~/.nvm but is frequently relocated.
  const nvmDir = absEnv(process.env.NVM_DIR) || path.join(home, '.nvm');
  _scanVersionedNodeModules(path.join(nvmDir, 'versions', 'node'), 'lib', paths);

  // fnm. Each version lives under `<base>/node-versions/<ver>/installation/`,
  // and fnm does NOT override the npm prefix, so globals are at
  // `installation/lib/node_modules`. The base dir is XDG-first
  // (`$XDG_DATA_HOME/fnm`, i.e. ~/.local/share/fnm on Linux and
  // ~/Library/Application Support/fnm on macOS); `~/.fnm` is only the legacy
  // fallback. `$FNM_DIR` overrides everything. Scan all candidate bases;
  // _scanVersionedNodeModules silently skips the ones that don't exist.
  const fnmSub = path.join('installation', 'lib');
  const fnmBases = [];
  if (absEnv(process.env.FNM_DIR)) fnmBases.push(process.env.FNM_DIR);
  if (absEnv(process.env.XDG_DATA_HOME)) fnmBases.push(path.join(process.env.XDG_DATA_HOME, 'fnm'));
  fnmBases.push(path.join(home, '.local', 'share', 'fnm'));            // Linux XDG default
  fnmBases.push(path.join(home, 'Library', 'Application Support', 'fnm')); // macOS default
  fnmBases.push(path.join(home, '.fnm'));                              // legacy
  for (const base of fnmBases) {
    _scanVersionedNodeModules(path.join(base, 'node-versions'), fnmSub, paths);
  }

  // Volta does NOT store global packages alongside the Node image. It
  // sandboxes each `npm install -g`'d package under
  // `<VOLTA_HOME>/tools/image/packages/<name>/lib/node_modules` (the scope
  // becomes a real nested directory). Because we know the package name, this
  // is a single fixed path rather than a version scan. VOLTA_HOME defaults to
  // ~/.volta on macOS/Linux but to %LOCALAPPDATA%\Volta on Windows (where the
  // globals actually live, and hook processes often don't inherit VOLTA_HOME).
  // Verified against volta-cli/volta `volta-layout` v4 (`package_image_dir`) +
  // `package/manager.rs` (`source_dir` = lib/node_modules).
  const voltaHome = absEnv(process.env.VOLTA_HOME)
    || (process.platform === 'win32'
          ? path.join(absEnv(process.env.LOCALAPPDATA) || path.join(home, 'AppData', 'Local'), 'Volta')
          : path.join(home, '.volta'));
  paths.push(path.join(voltaHome, 'tools', 'image', 'packages', '@evomap', 'evolver', 'lib', 'node_modules'));

  // asdf. Globals are per-version under `<data>/installs/nodejs/<ver>/`.
  // asdf-nodejs dropped the `.npm` prefix override in PR #228 (Sept 2022),
  // so modern installs use plain `lib/node_modules`; older installs (never
  // re-created) still use `.npm/lib/node_modules`. Scan both. `$ASDF_DATA_DIR`
  // overrides the ~/.asdf default (asdf 0.16+ Go rewrite).
  const asdfData = absEnv(process.env.ASDF_DATA_DIR) || path.join(home, '.asdf');
  const asdfVersions = path.join(asdfData, 'installs', 'nodejs');
  _scanVersionedNodeModules(asdfVersions, 'lib', paths);                       // modern (post-#228)
  _scanVersionedNodeModules(asdfVersions, path.join('.npm', 'lib'), paths);    // legacy (pre-#228)

  // Windows: `npm install -g` puts packages under %APPDATA%\npm\node_modules
  // (most common; same convention as `npm config get prefix` default on win32),
  // %ProgramFiles%\nodejs\node_modules (system-wide installer), or
  // %ProgramFiles(x86)%\nodejs\node_modules (32-bit Node on a 64-bit host).
  // Conditional expansion keeps the POSIX base list untouched on Linux/macOS.
  if (process.platform === 'win32') {
    const appdata = absEnv(process.env.APPDATA) || path.join(home, 'AppData', 'Roaming');
    paths.push(path.join(appdata, 'npm', 'node_modules'));
    if (absEnv(process.env.ProgramFiles)) {
      paths.push(path.join(process.env.ProgramFiles, 'nodejs', 'node_modules'));
    }
    if (absEnv(process.env['ProgramFiles(x86)'])) {
      paths.push(path.join(process.env['ProgramFiles(x86)'], 'nodejs', 'node_modules'));
    }
  }

  return paths;
}

function findEvolverRoot() {
  if (process.env.EVOLVER_ROOT) {
    const explicit = process.env.EVOLVER_ROOT;
    if (fs.existsSync(path.join(explicit, 'package.json')) &&
        isEvolverPackageJson(path.join(explicit, 'package.json'))) {
      return explicit;
    }
  }

  // Dev/repo layout: this file lives at src/adapters/scripts/_runtimePaths.js,
  // so `../../..` is the package root.
  const repoRoot = path.resolve(__dirname, '..', '..', '..');
  if (fs.existsSync(path.join(repoRoot, 'package.json')) &&
      isEvolverPackageJson(path.join(repoRoot, 'package.json'))) {
    return repoRoot;
  }

  // npm-global / npm-local install layout. The hook script may have been
  // copied out of the package into `.claude/hooks/` etc., breaking relative
  // walks. Use require.resolve to find the installed package authoritatively.
  //
  // SECURITY: do NOT include `process.cwd()` here. A hostile workspace can
  // place its own `node_modules/@evomap/evolver/package.json`, which would
  // be selected here and control `findMemoryGraph()` -> the memory graph
  // contents become attacker-controlled prompt-injection material in
  // `evolver-session-start.js`'s `additionalContext`. Restrict to trusted,
  // user/system-scoped install roots (built in `_buildInstallSearchPaths`).
  try {
    // Allowlist of trusted user/system-scoped install roots. Built by
    // _buildInstallSearchPaths() above so the list is one source of truth
    // (Apple Silicon Homebrew, Linuxbrew, NVM / fnm / Volta / asdf,
    // and Windows %APPDATA%\npm + %ProgramFiles%\nodejs install layouts).
    // process.cwd() is intentionally excluded: a hostile workspace can plant
    // its own node_modules/@evomap/evolver/package.json which would then
    // control findMemoryGraph() and feed attacker-controlled content into
    // evolver-session-start.js's additionalContext.
    const pkgJson = require.resolve('@evomap/evolver/package.json', {
      paths: _buildInstallSearchPaths(),
    });
    if (pkgJson && isEvolverPackageJson(pkgJson)) {
      return path.dirname(pkgJson);
    }
  } catch { /* not installed via npm */ }

  const homeSkills = path.join(os.homedir(), 'skills', 'evolver');
  if (fs.existsSync(path.join(homeSkills, 'package.json')) &&
      isEvolverPackageJson(path.join(homeSkills, 'package.json'))) {
    return homeSkills;
  }

  return null;
}

// Resolve the user's PROJECT directory — the workspace the agent is actually
// working in — for git-diff collection and workspace tagging.
//
// Why this exists: hook scripts must NOT assume `process.cwd()` is the project
// root. Cursor invokes some hook events (e.g. afterFileEdit) with the working
// directory set to the *plugin* install dir (`~/.cursor/plugins/local/<name>`),
// not the opened workspace. A hook that runs `git diff` in cwd would then look
// for changes in the plugin directory and find none — silently recording
// nothing for every task. Hosts expose the real workspace root via an env var:
//   - Cursor sets CURSOR_PROJECT_DIR (and a CLAUDE_PROJECT_DIR compat alias)
//   - Claude Code sets CLAUDE_PROJECT_DIR
// Codex / opencode / Kiro and direct CLI usage leave both unset, in which case
// `process.cwd()` is already the project root and remains the fallback — so
// this change is a no-op on those platforms.
//
// SECURITY: only honor an env value that points at an existing directory. A
// stale or empty value must not redirect git collection to a bogus path; we
// fall through to cwd instead. We intentionally do NOT recurse into evolver
// package discovery here — this is purely "where is the user's code".
function resolveProjectDir() {
  for (const key of ['CURSOR_PROJECT_DIR', 'CLAUDE_PROJECT_DIR']) {
    const v = process.env[key];
    if (typeof v === 'string' && v.trim()) {
      try {
        if (fs.statSync(v).isDirectory()) return v;
      } catch { /* not a usable dir — try next / fall back to cwd */ }
    }
  }
  return process.cwd();
}

// Determine the workspace ROOT for a project, mirroring src/gep/paths.js
// getWorkspaceRoot() step-for-step so the FS-only fallback lands its secret at
// the SAME path paths.js would (what lets an installed @evomap/evolver read the
// very same id):
//   1. OPENCLAW_WORKSPACE override.
//   2. else the git repo root at/above projectDir, BUT if that repo root has a
//      `workspace/` subdirectory, paths.js returns <repoRoot>/workspace — so we
//      must too, or the two land on different .evolver/workspace-id files (the
//      "read back identically" guarantee would break for such projects).
//   3. else projectDir.
function _fsWorkspaceRoot(projectDir) {
  if (process.env.OPENCLAW_WORKSPACE) return process.env.OPENCLAW_WORKSPACE;
  // Walk up from projectDir looking for a .git entry (file or dir) = repo root.
  let repoRoot = null;
  let dir = projectDir;
  while (dir) {
    if (fs.existsSync(path.join(dir, '.git'))) { repoRoot = dir; break; }
    const parent = path.dirname(dir);
    if (parent === dir) break;
    dir = parent;
  }
  if (!repoRoot) return projectDir;
  // Mirror getWorkspaceRoot()'s workspace/ subdir step.
  const workspaceDir = path.join(repoRoot, 'workspace');
  if (fs.existsSync(workspaceDir)) return workspaceDir;
  return repoRoot;
}

// FS-only re-implementation of src/gep/paths.js getWorkspaceId() for the case
// where the evolver package is not installed (plugin-only installs). It reads
// — and lazily, atomically creates — the per-workspace secret at
// <workspaceRoot>/.evolver/workspace-id. The format (16-byte hex), the path,
// the 0600 mode, the O_EXCL|O_NOFOLLOW atomic create, and the symlink
// rejection all match paths.js exactly, so a workspace seeded by this fallback
// is transparently picked up by paths.getWorkspaceId() once the package is
// present, and vice-versa. Returns null on any read/write error (caller then
// falls back to legacy cwd-tag matching — no regression).
// Read <dir>/workspace-id with the same symlink guards paths.js'
// _readWorkspaceIdFromFs uses: reject a symlinked .evolver dir, reject a
// symlinked / non-regular id file, and require hex format. Returns the id, or
// null on any error / missing file. Used for BOTH the initial read and the
// EEXIST race re-read so a symlink swapped in between our lstat and openSync
// can never be followed (Bugbot PR #557).
function _readWsIdGuarded(dir, file) {
  try {
    const dirStat = fs.lstatSync(dir, { throwIfNoEntry: false });
    if (dirStat && dirStat.isSymbolicLink()) return null;
    const fileStat = fs.lstatSync(file, { throwIfNoEntry: false });
    if (!fileStat) return null;
    if (fileStat.isSymbolicLink() || !fileStat.isFile()) return null;
    const raw = fs.readFileSync(file, 'utf8').trim();
    return raw && /^[a-f0-9]{32,}$/i.test(raw) ? raw : null;
  } catch { return null; }
}

function _fsWorkspaceId(projectDir) {
  // Whole body is wrapped: the documented contract is "returns null on ANY
  // read/write error" so the session-start/-end hooks degrade gracefully
  // rather than crash. throwIfNoEntry:false only suppresses ENOENT; EACCES/EIO
  // and friends still throw, so a bare lstat/mkdir here must not escape
  // (Bugbot PR #557 round-2 — an unguarded lstat could crash the hook).
  try {
    const dir = path.join(_fsWorkspaceRoot(projectDir), '.evolver');
    const file = path.join(dir, 'workspace-id');
    // Read first, with symlink guards.
    const existing = _readWsIdGuarded(dir, file);
    if (existing) return existing;
    // If the file exists but the guards rejected it (symlink / bad format),
    // refuse rather than create over it.
    if (fs.lstatSync(file, { throwIfNoEntry: false })) return null;
    // Missing — create atomically. Refuse a symlinked .evolver dir (O_NOFOLLOW
    // only guards the final component, not intermediate dirs).
    const dirStat = fs.lstatSync(dir, { throwIfNoEntry: false });
    if (dirStat && dirStat.isSymbolicLink()) return null;
    fs.mkdirSync(dir, { recursive: true });
    const payload = require('crypto').randomBytes(16).toString('hex');
    const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL |
      (fs.constants.O_NOFOLLOW || 0);
    let fd;
    try {
      fd = fs.openSync(file, flags, 0o600);
    } catch (e) {
      // Lost a race — re-read WITH the same symlink guards (paths.js does the
      // same). A bare readFileSync here would follow a symlink swapped in
      // after our dir lstat (Bugbot PR #557).
      if (e && e.code === 'EEXIST') return _readWsIdGuarded(dir, file);
      return null; // ELOOP/EMLINK from O_NOFOLLOW hitting a symlink — refuse.
    }
    try { fs.writeSync(fd, payload + '\n', 0, 'utf8'); } finally { fs.closeSync(fd); }
    try { fs.chmodSync(file, 0o600); } catch { /* best-effort */ }
    return payload;
  } catch { return null; }
}

// Resolve the current workspace id — the forge-resistant tag the session-end
// writer stamps on every memory-graph entry (`workspace_id`). This is the
// SINGLE source of that resolution: the session-end writer stamps it and the
// session-start reader scopes by it, so both call this one function. Keeping
// it here (rather than a copy per hook) is what guarantees reader and writer
// can never drift apart — if they resolved different ids, no entry would ever
// match the reader's filter and workspace scoping would silently break.
// Resolution order:
//   1. EVOLVER_WORKSPACE_ID env override
//   2. paths.getWorkspaceId() loaded from the resolved evolver root (this is
//      the richer path — it can additionally back the secret with the OS
//      keychain when @napi-rs/keyring is installed).
//   3. FS-only fallback for plugin-only installs where the evolver package is
//      not reachable. Without this, plugin users got workspace_id=null and the
//      forge-resistant scoping silently degraded to cwd-tag matching (found
//      via real-Cursor end-to-end testing). The fallback writes the same
//      secret file paths.js uses, so installing the package later is seamless.
// Still returns null if even the FS write fails — callers must then NOT filter
// (show everything), preserving prior behavior rather than hiding all memory.
function resolveWorkspaceId(evolverRoot, projectDir) {
  if (process.env.EVOLVER_WORKSPACE_ID) return String(process.env.EVOLVER_WORKSPACE_ID);
  const root = evolverRoot || findEvolverRoot();
  if (root) {
    try {
      const paths = require(path.join(root, 'src', 'gep', 'paths.js'));
      if (typeof paths.getWorkspaceId === 'function') return paths.getWorkspaceId();
    } catch { /* paths.js unreachable — fall through to FS-only */ }
  }
  return _fsWorkspaceId(projectDir || resolveProjectDir());
}

// Returns a path to the evolution memory graph, or a fallback location that
// is guaranteed to be writable. Never returns null — when no evolver root is
// available, we fall back to `~/.evolver/memory/evolution/memory_graph.jsonl`
// so npm-global installs without a project-local evolver still capture
// outcomes (#536). Callers that need a "does the file already exist" check
// should use `fs.existsSync()` separately.
function findMemoryGraph(evolverRoot) {
  if (process.env.MEMORY_GRAPH_PATH) {
    return process.env.MEMORY_GRAPH_PATH;
  }
  if (evolverRoot) {
    const lower = path.join(evolverRoot, 'memory', 'evolution', 'memory_graph.jsonl');
    if (fs.existsSync(lower)) return lower;
    const upper = path.join(evolverRoot, 'MEMORY', 'evolution', 'memory_graph.jsonl');
    if (fs.existsSync(upper)) return upper;
    // Neither exists yet — prefer lowercase under the evolver root if the
    // root itself is writable (dev/local install case).
    try {
      fs.accessSync(evolverRoot, fs.constants.W_OK);
      const dir = path.dirname(lower);
      try { fs.mkdirSync(dir, { recursive: true }); } catch { /* fall through */ }
      return lower;
    } catch { /* not writable, fall through to user-level */ }
  }

  // User-level fallback. Always writable, consistent across platforms.
  const userDir = path.join(os.homedir(), '.evolver', 'memory', 'evolution');
  try { fs.mkdirSync(userDir, { recursive: true }); } catch { /* best-effort */ }
  return path.join(userDir, 'memory_graph.jsonl');
}

// Is `dir` inside a git work tree? Cheap, no-shell `git rev-parse`. Returns
// false on any error (git missing, not a repo, timeout) and never throws.
// The session-start hook uses this only to decide whether to surface a
// one-line "evolver needs a git workspace" notice, so a false negative just
// suppresses the notice rather than breaking anything.
function isGitWorkspace(dir) {
  try {
    const res = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], {
      cwd: dir,
      encoding: 'utf8',
      timeout: 5000,
      stdio: ['ignore', 'pipe', 'pipe'],
      shell: false,
      windowsHide: true,
    });
    return res.status === 0 && typeof res.stdout === 'string' && res.stdout.trim() === 'true';
  } catch {
    return false;
  }
}

module.exports = {
  findEvolverRoot,
  findMemoryGraph,
  resolveProjectDir,
  resolveWorkspaceId,
  isGitWorkspace,
  // Test-only: exposes the install-path builder so the test suite can
  // verify Apple Silicon Homebrew + version-manager (NVM/fnm/Volta/asdf)
  // + Windows %APPDATA%\npm paths are included without going through the
  // full require.resolve chain (which depends on a real filesystem layout).
  __internals: {
    buildInstallSearchPaths: _buildInstallSearchPaths,
    scanVersionedNodeModules: _scanVersionedNodeModules,
  },
};
