/**
 * Hermes plugin-directory shim lifecycle (Issue #1929).
 *
 * Hermes Agent discovers memory providers by scanning
 * `$HERMES_HOME/plugins/<name>/__init__.py` — NOT pip metadata and NOT any
 * plugin.yaml. The discovery heuristic reads the first 8KB of that file and
 * keeps the directory only when the source text contains the literal string
 * `register_memory_provider` or `MemoryProvider`. A bare `pip install
 * remnic-hermes` therefore leaves the provider invisible until the CLI
 * materializes this directory shim. The loader then calls the module's
 * `register(collector)`; `remnic_hermes.register()` (>= 1.0.5) loads Hermes
 * config itself when the collector exposes no `.config`.
 *
 * Activation still requires the user to set `memory.provider: remnic` in their
 * Hermes config.yaml — an exclusive slot the installer never touches
 * programmatically.
 *
 * This module isolates every Hermes-discovery-contract detail (home
 * resolution, discovery heuristic, shim content, marker provenance) so the
 * generic connector registry in `index.ts` only orchestrates. Verified against
 * upstream `NousResearch/hermes-agent` @ `53adb3f`
 * (`plugins/memory/__init__.py`, `hermes_constants.py`).
 */

import fs from "node:fs";
import path from "node:path";

import { readEnvVar, resolveHomeDir } from "../runtime/env.js";
import { expandTildePath } from "../utils/path.js";

/**
 * Stable marker embedded in the generated shim's docstring. `remove` uses it to
 * distinguish a Remnic-authored shim from a user-authored `__init__.py` so we
 * never delete a file we did not write.
 */
export const HERMES_SHIM_MARKER = "generated by `remnic connectors install hermes`";

/**
 * Resolve the Hermes home directory. Honors `HERMES_HOME` (tilde-expanded and
 * resolved to an absolute path) when set; otherwise falls back to Hermes'
 * platform-native default, mirroring upstream `_get_platform_default_hermes_home`
 * in `hermes_constants.py`: `%LOCALAPPDATA%\hermes` on Windows (with
 * `~/AppData/Local/hermes` when LOCALAPPDATA is unset), `~/.hermes` elsewhere.
 * If `HERMES_HOME` points at an existing non-directory path it is a
 * misconfiguration — throw rather than attempt to create a `plugins/` subtree
 * under a regular file.
 */
export function resolveHermesRoot(): string {
  const envHome = readEnvVar("HERMES_HOME");
  if (typeof envHome === "string" && envHome.trim().length > 0) {
    const expanded = path.resolve(expandTildePath(envHome.trim()));
    // lstat (no symlink following): an externally supplied HERMES_HOME that is
    // a symlink could redirect config/shim writes and deletions to an
    // arbitrary directory — reject symlinked roots outright, matching the
    // repository's symlink-traversal guard for directory scans (Codex P1 on
    // PR #1938, round 15). A non-directory (regular file) root is likewise a
    // misconfiguration.
    try {
      const stat = fs.lstatSync(expanded);
      if (stat.isSymbolicLink()) {
        throw new Error(`HERMES_HOME must not be a symbolic link: ${expanded}`);
      }
      if (!stat.isDirectory()) {
        throw new Error(`HERMES_HOME is not a directory: ${expanded}`);
      }
    } catch (err) {
      if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
        throw err;
      }
      // Does not exist yet — acceptable; install creates it.
    }
    return expanded;
  }
  if (process.platform === "win32") {
    const localAppData = (readEnvVar("LOCALAPPDATA") ?? "").trim();
    const base = localAppData.length > 0 ? path.resolve(localAppData) : path.join(resolveHomeDir(), "AppData", "Local");
    return resolveDefaultRoot(path.join(base, "hermes"));
  }
  return resolveDefaultRoot(path.resolve(resolveHomeDir(), ".hermes"));
}

/**
 * Normalize the platform-default Hermes root. Unlike an explicitly supplied
 * `HERMES_HOME` (external input — symlinks rejected outright above), the
 * default `~/.hermes` / `%LOCALAPPDATA%\hermes` is commonly a symlink under
 * dotfile managers, and Hermes itself follows it. Resolving to the realpath
 * (rather than rejecting) removes the divergence a symlinked root could
 * introduce: every subsequent read/write/remove derives from the SAME
 * resolved base, and the component-level symlink guard still protects the
 * subtree below it (Codex P1 on PR #1938, round 17).
 */
function resolveDefaultRoot(candidate: string): string {
  try {
    return fs.realpathSync.native(candidate);
  } catch {
    return candidate; // does not exist yet — created on demand
  }
}

export function hermesShimPath(): string {
  return path.join(resolveHermesRoot(), "plugins", "remnic", "__init__.py");
}

/**
 * Atomically write a plain (non-secret) file with 0o644 permissions. The shim
 * carries no credentials, so unlike `writeSecretFileSync` it is world-readable.
 * Writing to a temp file then renaming into place guarantees a mid-write failure
 * cannot truncate an existing shim (AGENTS.md: never destroy old state before
 * the new state is confirmed).
 */
function writePlainFileAtomicSync(filePath: string, data: string): void {
  const dir = path.dirname(filePath);
  const base = path.basename(filePath);
  const tmpPath = path.join(dir, `.${base}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`);
  let wroteTemp = false;
  try {
    fs.writeFileSync(tmpPath, data, { mode: 0o644, flag: "wx" });
    wroteTemp = true;
    fs.renameSync(tmpPath, filePath);
    try {
      fs.chmodSync(filePath, 0o644);
    } catch {
      /* best-effort on non-POSIX filesystems */
    }
  } catch (err) {
    if (wroteTemp) {
      try {
        fs.unlinkSync(tmpPath);
      } catch {
        /* best-effort temp cleanup */
      }
    }
    throw err;
  }
}

/**
 * Reject symlinked path components between the Hermes root and the shim file
 * (`plugins/`, `plugins/remnic/`, and the `__init__.py` itself). The root-only
 * lstat in `resolveHermesRoot` does not cover these; a symlinked component
 * would let mkdir/write/unlink escape the selected Hermes home (Codex P1 on
 * PR #1938, round 16). Missing components are fine — they get created.
 */
function assertShimComponentsNotSymlinked(shimPath: string): void {
  const remnicDir = path.dirname(shimPath);
  const pluginsDir = path.dirname(remnicDir);
  for (const component of [pluginsDir, remnicDir, shimPath]) {
    let isLink = false;
    try {
      isLink = fs.lstatSync(component).isSymbolicLink();
    } catch {
      continue; // does not exist yet
    }
    if (isLink) {
      throw new Error(`refusing to operate through a symbolic link: ${component}`);
    }
  }
}

/**
 * Materialize the Hermes plugin-directory shim. Idempotent:
 * - A shim already carrying our marker is overwritten (reinstall).
 * - A shim WITHOUT our marker is user-authored → left untouched
 *   (`wrote: false`) so callers never treat the collision as a materialized
 *   replacement.
 * - Otherwise the parent dirs are created and the shim is written.
 * Returns a human-readable note plus whether the shim was actually written.
 * Throws only on a real filesystem failure; the caller wraps this so install
 * never fails on the shim.
 */
export function materializeHermesShim(shimPath: string): { note: string; wrote: boolean } {
  assertShimComponentsNotSymlinked(shimPath);
  if (fs.existsSync(shimPath)) {
    let existing: string;
    try {
      existing = fs.readFileSync(shimPath, "utf8");
    } catch (readErr) {
      // An unreadable existing file cannot be classified as ours vs
      // user-authored. Treating it as user-authored would silently skip a
      // reinstall of OUR marker shim (Bugbot on PR #1938); surface the read
      // failure instead so the caller emits the manual hint.
      throw new Error(
        `cannot read existing shim at ${shimPath}: ${readErr instanceof Error ? readErr.message : String(readErr)}`
      );
    }
    if (!existing.includes(HERMES_SHIM_MARKER)) {
      // Collision with a user-authored file: nothing was written. Callers must
      // treat this as an UNCONFIRMED replacement (wrote: false) — never as a
      // materialized shim (Codex P2 on PR #1938, round 8).
      return {
        note: `Hermes plugin shim already exists and was NOT generated by Remnic — left untouched: ${shimPath}. If the provider is not discovered, ensure that file imports remnic_hermes.register AND contains the literal text register_memory_provider (or MemoryProvider) — Hermes' discovery text-scan skips the directory without it.`,
        wrote: false,
      };
    }
  }
  fs.mkdirSync(path.dirname(shimPath), { recursive: true });
  const content = [
    `"""Remnic memory provider shim for Hermes Agent (${HERMES_SHIM_MARKER}).`,
    "",
    "Hermes memory-provider discovery calls register(collector);",
    "collector.register_memory_provider() receives the provider.",
    `"""`,
    "",
    "from remnic_hermes import register  # noqa: F401  (register() loads Hermes config itself)",
    "",
  ].join("\n");
  writePlainFileAtomicSync(shimPath, content);
  return { note: `Materialized Hermes plugin shim: ${shimPath}`, wrote: true };
}

/**
 * Reconcile the on-disk Remnic shim with the shim location resolved from the
 * CURRENT environment. One routine for both install paths (fresh/force install
 * and the already_installed backfill) so they enforce the same invariant: a
 * Remnic-generated shim on disk is either removed or referenced by the
 * returned `persistPath` — the caller must persist that path in the connector
 * JSON and never silently discard the location of a shim that survives on
 * disk.
 *
 * Behavior:
 * - Resolves the target path (`hermesShimPath()`), materializes the shim
 *   there, THEN removes a marker shim at `priorPersistedPath` when it differs
 *   (never destroy old state before the new state is confirmed).
 * - On resolution/materialization failure: emits a manual-creation hint with
 *   the exact required contents (including the `register_memory_provider`
 *   discovery literal) and returns the PRIOR path as `persistPath`, so the
 *   registry keeps tracking the shim that is still on disk.
 * - Never throws.
 */
export function reconcileHermesShim(priorPersistedPath: string | null): {
  notes: string[];
  persistPath: string | null;
  /** Path where a shim was (re)written by this reconcile, or null. */
  materializedAt: string | null;
  /** True when the materialized shim did NOT exist before this reconcile. */
  createdNew: boolean;
  /** Prior-install shim path this reconcile actually deleted, or null. */
  priorCleanedAt: string | null;
} {
  const notes: string[] = [];
  let target: string | null = null;
  let materialized = false;
  let createdNew = false;
  try {
    target = hermesShimPath();
    // Record create-vs-overwrite BEFORE writing: a rollback must never delete
    // a shim that pre-existed this reconcile (e.g. connector JSON deleted
    // manually while the marker shim survived) — overwriting our own
    // deterministic content is a no-op, but deleting it would break a
    // functional install (Codex P2 on PR #1938, round 7).
    const existedBefore = fs.existsSync(target);
    const result = materializeHermesShim(target);
    notes.push(result.note);
    // A collision with a user-authored file writes nothing: treat it as an
    // unconfirmed replacement so the prior generated shim is NOT cleaned and
    // the registry keeps pointing at the shim that actually works
    // (Codex P2 on PR #1938, round 8).
    materialized = result.wrote;
    createdNew = result.wrote && !existedBefore;
  } catch (shimErr) {
    const shimPathHint = target ?? "<hermesRoot>/plugins/remnic/__init__.py";
    // No shell one-liner here: the path may contain characters that break
    // quoting, and the shim content MUST include the literal text
    // `register_memory_provider` (or `MemoryProvider`) or Hermes' discovery
    // text-scan skips the directory (PR #1938 review).
    notes.push(
      `Note: could not materialize the Hermes plugin shim (${shimErr instanceof Error ? shimErr.message : String(shimErr)}). Create ${shimPathHint} manually with exactly these two lines:\n    """Remnic memory provider shim. Calls collector.register_memory_provider()."""\n    from remnic_hermes import register`
    );
  }
  if (!materialized || target === null) {
    // Nothing new confirmed on disk — keep tracking ONLY what the registry
    // already pointed at. Never persist the unconfirmed target: provenance
    // must reference a Remnic shim that actually exists (Bugbot on PR #1938,
    // round 8).
    return {
      notes,
      persistPath: priorPersistedPath,
      materializedAt: null,
      createdNew: false,
      priorCleanedAt: null,
    };
  }
  const confirmedTarget: string = target;
  let priorCleanedAt: string | null = null;
  if (priorPersistedPath !== null && !sameShimTarget(priorPersistedPath, confirmedTarget)) {
    // The prior install's shim lives elsewhere (HERMES_HOME changed). Compare
    // by resolved file identity, not string equality — two spellings of the
    // same directory (symlinks, case-insensitive volumes, tilde vs absolute)
    // must not delete the shim that was just written (Bugbot on PR #1938,
    // round 7). Clean it now that the replacement is confirmed; marker-gating
    // inside removeHermesShim protects user-authored files.
    try {
      const stale = removeHermesShim([priorPersistedPath]);
      if (stale.notes.length > 0) {
        notes.push(`Cleaned prior-install shim: ${stale.notes.join("; ")}`);
      }
      priorCleanedAt = stale.removedPaths.includes(priorPersistedPath) ? priorPersistedPath : null;
    } catch {
      notes.push(
        `Note: could not clean the prior-install Hermes plugin shim at ${priorPersistedPath} — remove it manually if present.`
      );
    }
  }
  return { notes, persistPath: target, materializedAt: target, createdNew, priorCleanedAt };
}

/**
 * Compare two shim paths by resolved file identity (realpath when available),
 * so symlinked, tilde-expanded, or differently-cased spellings of the same
 * location are treated as equal.
 */
export function sameShimTarget(leftPath: string, rightPath: string): boolean {
  return resolveShimTarget(leftPath) === resolveShimTarget(rightPath);
}

function resolveShimTarget(candidate: string): string {
  try {
    return fs.realpathSync.native(candidate);
  } catch {
    return path.resolve(candidate);
  }
}

/**
 * Remove the Hermes plugin-directory shim ONLY when it carries our generated
 * marker (never delete a user-authored `__init__.py`). Accepts every candidate
 * location — the path persisted in connector.json at install time plus the
 * path resolved from the CURRENT environment — so a HERMES_HOME change between
 * install and remove cannot orphan the generated shim (Codex P2 on PR #1938).
 * After deleting, attempts to remove the now-empty `plugins/remnic/` directory
 * with rmdir — which fails harmlessly if the directory is non-empty or
 * missing. Never rm -rf. Returns the paths actually deleted plus
 * human-readable notes (empty when there was nothing of ours to act on).
 */
export function removeHermesShim(candidatePaths: readonly string[]): {
  removedPaths: string[];
  notes: string[];
} {
  const notes: string[] = [];
  const removedPaths: string[] = [];
  for (const shimPath of new Set(candidatePaths)) {
    // Only ever touch a path with the exact generated-shim shape. The
    // persisted candidate comes from connector.json, which is on-disk state —
    // do not let a tampered value point this cleanup at an arbitrary file.
    if (!isPlausibleHermesShimPath(shimPath)) {
      continue;
    }
    try {
      assertShimComponentsNotSymlinked(shimPath);
    } catch {
      notes.push(`Hermes plugin shim left untouched (symlinked path component): ${shimPath}`);
      continue;
    }
    if (!fs.existsSync(shimPath)) {
      continue;
    }
    let content: string;
    try {
      content = fs.readFileSync(shimPath, "utf8");
    } catch {
      // Fail safe: an unreadable file cannot be verified as ours — never
      // delete what we cannot positively identify.
      notes.push(`Hermes plugin shim left untouched (unreadable): ${shimPath}`);
      continue;
    }
    if (!content.includes(HERMES_SHIM_MARKER)) {
      notes.push(`Hermes plugin shim left untouched (not Remnic-generated): ${shimPath}`);
      continue;
    }
    try {
      fs.unlinkSync(shimPath);
    } catch (unlinkErr) {
      // One stale/unwritable candidate must not abort cleanup of the rest
      // (Codex P2 on PR #1938, round 20). The failed path stays on disk and,
      // still carrying the marker, remains tracked by the provenance
      // reconciliation for a later retry.
      notes.push(
        `Hermes plugin shim could not be removed (${unlinkErr instanceof Error ? unlinkErr.message : String(unlinkErr)}): ${shimPath} — remove it manually or re-run after fixing permissions.`,
      );
      continue;
    }
    removedPaths.push(shimPath);
    try {
      fs.rmdirSync(path.dirname(shimPath));
    } catch {
      /* directory non-empty or already gone — leave it in place */
    }
    notes.push(`Removed Hermes plugin shim: ${shimPath}`);
  }
  return { removedPaths, notes };
}

/**
 * Shape guard for shim paths read back from connector.json: absolute, named
 * `__init__.py`, and living under a `plugins/remnic/` directory.
 */
function isPlausibleHermesShimPath(candidate: string): boolean {
  if (typeof candidate !== "string" || candidate.length === 0 || !path.isAbsolute(candidate)) {
    return false;
  }
  const dir = path.dirname(candidate);
  return (
    path.basename(candidate) === "__init__.py" &&
    path.basename(dir) === "remnic" &&
    path.basename(path.dirname(dir)) === "plugins"
  );
}

/**
 * Shape guard for config.yaml paths read back from connector.json: absolute,
 * named `config.yaml`, and living in a directory that looks like a Hermes
 * home or Hermes profile — the home directory is named `.hermes`/`hermes`,
 * the path sits under a `profiles/` directory, or the directory carries a
 * Hermes-layout sibling (`plugins/` or `profiles/`). A tampered connector
 * JSON must not be able to point remnic:-block cleanup at an arbitrary
 * config.yaml elsewhere on disk (Codex P2 on PR #1938, round 7).
 */
export function isPlausibleHermesConfigPath(candidate: string): boolean {
  if (typeof candidate !== "string" || candidate.length === 0 || !path.isAbsolute(candidate)) {
    return false;
  }
  if (path.basename(candidate) !== "config.yaml") {
    return false;
  }
  const dir = path.dirname(candidate);
  const dirName = path.basename(dir).toLowerCase();
  if (dirName === ".hermes" || dirName === "hermes") {
    return true;
  }
  if (path.basename(path.dirname(dir)) === "profiles") {
    return true;
  }
  if (
    ["plugins", "profiles"].some((sibling) => {
      try {
        return fs.statSync(path.join(dir, sibling)).isDirectory();
      } catch {
        return false;
      }
    })
  ) {
    return true;
  }
  // Last resort for custom HERMES_HOME dirs with an arbitrary basename and no
  // Hermes-layout siblings (e.g. shim materialization failed so plugins/ was
  // never created): accept the path when the file itself currently carries a
  // top-level remnic: block — cleanup only ever strips that block, so the
  // content check bounds what a tampered path could affect (Bugbot on
  // PR #1938, round 9).
  try {
    const stat = fs.statSync(candidate);
    if (!stat.isFile() || stat.size > 1024 * 1024) {
      return false;
    }
    return /^remnic:/m.test(fs.readFileSync(candidate, "utf8"));
  } catch {
    return false;
  }
}

/**
 * Return every candidate that still holds a Remnic marker shim on disk —
 * shape-guarded, file-identity de-duplicated, and excluding `excludeTarget`
 * (the currently-tracked shim). Callers persist the result as
 * `priorPluginShimPaths` so a shim whose cleanup failed is never orphaned:
 * a later install or remove keeps targeting it (Codex P2 on PR #1938,
 * round 18).
 */
export function survivingMarkerShims(
  candidates: readonly string[],
  excludeTarget: string | null,
): string[] {
  const survivors: string[] = [];
  for (const candidate of new Set(candidates)) {
    if (!isPlausibleHermesShimPath(candidate)) {
      continue;
    }
    if (excludeTarget !== null && sameShimTarget(candidate, excludeTarget)) {
      continue;
    }
    if (survivors.some((kept) => sameShimTarget(kept, candidate))) {
      continue;
    }
    let carriesMarker = false;
    try {
      carriesMarker = fs.readFileSync(candidate, "utf8").includes(HERMES_SHIM_MARKER);
    } catch {
      carriesMarker = false;
    }
    if (carriesMarker) {
      survivors.push(candidate);
    }
  }
  return survivors;
}

/**
 * Reject symlinked path components between the Hermes root and a config.yaml
 * target (`profiles/`, `profiles/<name>/`, and the file itself). Mirrors the
 * shim component guard: a symlinked component below the accepted root could
 * redirect the token-bearing config write or removal rewrite outside the
 * selected Hermes home (Codex P1 on PR #1938, round 19). Missing components
 * are fine — they get created.
 */
export function assertConfigComponentsNotSymlinked(cfgPath: string): void {
  const dir = path.dirname(cfgPath);
  const components = [cfgPath, dir];
  const grandparent = path.dirname(dir);
  if (path.basename(grandparent) === "profiles") {
    components.push(grandparent);
  }
  for (const component of components) {
    let isLink = false;
    try {
      isLink = fs.lstatSync(component).isSymbolicLink();
    } catch {
      continue; // does not exist yet
    }
    if (isLink) {
      throw new Error(`refusing to operate through a symbolic link: ${component}`);
    }
  }
}
